faynoSync_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"bytes"
"compress/gzip"
"context"
"crypto/md5"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"faynoSync/mongod"
"faynoSync/redisdb"
"faynoSync/server/handler"
"faynoSync/server/handler/info"
"faynoSync/server/model"
"faynoSync/server/utils"
"faynoSync/server/utils/storage"
faynosync "github.com/ku9nov/faynosync-sdk-go"
"github.com/beevik/etree"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/x/mongo/driver/connstring"
)
var (
client *mongo.Client
appDB mongod.AppRepository
mongoDatabase *mongo.Database
configDB connstring.ConnString
apiKey string
apiUrl string
s3Endpoint string
s3Bucket string
redisClient *redis.Client
)
func TestMain(m *testing.M) {
// Set up resources before running tests
setup()
// Run the tests
code := m.Run()
teardown()
os.Exit(code)
}
func checkFileContent(t *testing.T, url string) string {
client := &http.Client{
Timeout: 10 * time.Second,
}
// GCS serves public objects from Google's edge cache for max-age, so an overwritten feed reads stale without a unique query.
if viper.GetString("STORAGE_DRIVER") == "gcp" {
url = fmt.Sprintf("%s?nocache=%d", url, time.Now().UnixNano())
}
resp, err := client.Get(url)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// Read the response body
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
// Log the content of the file
logrus.Infof("Content of %s:\n%s", url, string(body))
return string(body)
}
func copyFile(src, dst string) {
input, err := os.ReadFile(src)
if err != nil {
logrus.Errorf("Failed to read the file: %v", err)
return
}
err = os.WriteFile(dst, input, 0644)
if err != nil {
logrus.Errorf("Failed to copy the file: %v", err)
return
}
}
func generateFile(filename, content string) {
err := os.WriteFile(filename, []byte(content), 0644)
if err != nil {
logrus.Errorf("Failed to generate the file %s: %v", filename, err)
return
}
}
func removeFile(filename string) {
err := os.Remove(filename)
if err != nil {
logrus.Errorf("Failed to remove the file: %v", err)
return
}
}
func assertSDKMatchesScenario(t *testing.T, expected map[string]interface{}, actual *faynosync.UpdateResponse) {
t.Helper()
require.Equal(t, expected["update_available"], actual.UpdateAvailable)
require.Equal(t, expected["critical"], actual.Critical)
if expectedIntermediate, ok := expected["is_intermediate_required"]; ok {
require.Equal(t, expectedIntermediate, actual.IsIntermediateRequired)
}
if expectedRollback, ok := expected["possible_rollback"]; ok {
rollbackField := reflect.ValueOf(actual).Elem().FieldByName("PossibleRollback")
require.True(t, rollbackField.IsValid(), "expected field PossibleRollback to exist when possible_rollback is provided")
require.Equal(t, reflect.Bool, rollbackField.Kind(), "expected field PossibleRollback to be bool when possible_rollback is provided")
require.Equal(t, expectedRollback, rollbackField.Bool())
}
if expectedChangelog, ok := expected["changelog"]; ok {
require.Equal(t, expectedChangelog, actual.Changelog)
}
if expectedURL, ok := expected["update_url"]; ok {
require.Equal(t, expectedURL, actual.UpdateURL)
}
actualPackages := make(map[string]string, len(actual.PackageURLs))
for _, pkg := range actual.PackageURLs {
actualPackages[pkg.Package] = pkg.URL
}
for key, expectedValue := range expected {
if !strings.HasPrefix(key, "update_url_") {
continue
}
pkg := strings.TrimPrefix(key, "update_url_")
require.Equal(t, expectedValue, actualPackages[pkg], "package URL mismatch for %s", pkg)
}
}
func setup() {
viper.SetConfigType("env")
viper.SetConfigName(".env")
// set the configuration file path
viper.AddConfigPath(".")
// read in the configuration file
// Same as the API: the file is optional, environment variables (e.g. from Compose) take precedence
if err := viper.ReadInConfig(); err != nil {
logrus.Infoln(".env file not found, using system variables")
}
viper.AutomaticEnv()
// Create a single database connection
s3Bucket = viper.GetString("S3_BUCKET_NAME")
s3Endpoint = viper.GetString("S3_ENDPOINT")
if viper.GetString("STORAGE_DRIVER") == "digitalocean" {
s3Endpoint = fmt.Sprintf("https://%s.%s", s3Bucket, s3Endpoint)
}
if viper.GetString("STORAGE_DRIVER") == "gcp" {
s3Endpoint = fmt.Sprintf("https://storage.googleapis.com/%s", s3Bucket)
}
apiUrl = viper.GetString("API_URL")
client, configDB = mongod.ConnectToDatabase(viper.GetString("MONGODB_URL_TESTS"))
if err := mongod.RunMigrationsUp(client, configDB.Database); err != nil {
panic(err)
}
appDB = mongod.NewAppRepository(&configDB, client)
mongoDatabase = client.Database(configDB.Database)
redisConfig := redisdb.RedisConfig{
Addr: viper.GetString("REDIS_HOST") + ":" + viper.GetString("REDIS_PORT"),
Password: viper.GetString("REDIS_PASSWORD"),
DB: viper.GetInt("REDIS_DB"),
}
redisClient = redisdb.ConnectToRedis(redisConfig)
os.Setenv("API_KEY", viper.GetString("API_KEY"))
apiKey = viper.GetString("API_KEY")
copyFile("LICENSE", "testapp.dmg")
copyFile("LICENSE", "testapp.pkg")
generateFile("RELEASES", "RELEASES FILE FOR WINDOWS sqirrel")
generateFile("latest.yml", "latest file for windows electron-builder")
generateFile("latest-mac.yml", "latest file for macos electron builder")
copyFile("LICENSE", "test.zip")
copyFile("LICENSE", "test.exe")
}
func teardown() {
if redisClient != nil {
err := redisClient.FlushDB(context.Background()).Err()
if err != nil {
logrus.Errorf("Failed to flush Redis DB: %v", err)
} else {
logrus.Infoln("Redis DB flushed successfully.")
}
}
adminsCollection := mongoDatabase.Collection("admins")
filter := bson.M{"username": bson.M{"$in": []string{"admin", "administrator"}}}
// Delete the admin user from the collection
_, err := adminsCollection.DeleteMany(context.TODO(), filter)
if err != nil {
logrus.Errorf("Failed to remove admin users: %v", err)
}
logrus.Infoln("Successfully removed admin users.")
client.Disconnect(context.Background())
logrus.Infoln("MongoDB is disconnected.")
removeFile("testapp.dmg")
removeFile("testapp.pkg")
removeFile("RELEASES")
removeFile("latest.yml")
removeFile("latest-mac.yml")
removeFile("test.zip")
removeFile("test.exe")
}
func generateDateRangeAndStats(startDate time.Time, days int) ([]interface{}, []interface{}) {
var dateRange []interface{}
var dailyStats []interface{}
for i := 0; i < days; i++ {
d := startDate.AddDate(0, 0, i).Format("2006-01-02")
dateRange = append(dateRange, d)
dailyStats = append(dailyStats, d)
}
return dateRange, dailyStats
}
func TestHealthCheck(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/health", func(c *gin.Context) {
handler.HealthCheck(c)
})
req, _ := http.NewRequest("GET", "/health", nil)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Check the response body.
expected := `{"status":"healthy"}`
assert.Equal(t, expected, w.Body.String())
}
func TestAuthRateLimits(t *testing.T) {
require.NotNil(t, redisClient)
ctx := context.Background()
// Fixed windows reset on the minute; start early in one so no subtest straddles a boundary.
if s := time.Now().Second(); s > 40 {
time.Sleep(time.Duration(61-s) * time.Second)
}
const rlUser = "ratelimitadmin"
const rlPassword = "ratelimit-password"
resetLimits := func() {
keys, err := redisClient.Keys(ctx, "auth:rl:*").Result()
require.NoError(t, err)
if len(keys) > 0 {
require.NoError(t, redisClient.Del(ctx, keys...).Err())
}
}
cleanup := func() {
resetLimits()
_, err := mongoDatabase.Collection("admins").DeleteOne(ctx, bson.M{"username": rlUser})
require.NoError(t, err)
}
cleanup()
t.Cleanup(cleanup)
h := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router := gin.Default()
router.POST("/signup", h.SignUp)
router.POST("/login", h.Login)
send := func(path, ip, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = ip + ":1234"
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
login := func(ip, username, password string) *httptest.ResponseRecorder {
return send("/login", ip, fmt.Sprintf(`{"username":%q,"password":%q}`, username, password))
}
signup := func(ip, username, key string) *httptest.ResponseRecorder {
return send("/signup", ip, fmt.Sprintf(`{"username":%q,"password":%q,"api_key":%q}`, username, rlPassword, key))
}
assertLimited := func(t *testing.T, w *httptest.ResponseRecorder, maxRetryAfter int) {
t.Helper()
require.Equal(t, http.StatusTooManyRequests, w.Code, w.Body.String())
var retryAfter int
_, err := fmt.Sscan(w.Header().Get("Retry-After"), &retryAfter)
require.NoError(t, err)
require.True(t, retryAfter > 0 && retryAfter <= maxRetryAfter, "Retry-After %d", retryAfter)
assert.JSONEq(t, `{"error":"too many attempts, try again later"}`, w.Body.String())
}
t.Run("signup and login succeed under the limits", func(t *testing.T) {
resetLimits()
require.Equal(t, http.StatusOK, signup("10.1.0.1", rlUser, apiKey).Code)
w := login("10.1.0.1", rlUser, rlPassword)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "token")
})
t.Run("successful login resets the per-username counter", func(t *testing.T) {
resetLimits()
for i := 0; i < 4; i++ {
require.Equal(t, http.StatusUnauthorized, login(fmt.Sprintf("10.2.0.%d", i), rlUser, "wrong").Code)
}
require.Equal(t, http.StatusOK, login("10.2.1.1", rlUser, rlPassword).Code)
for i := 0; i < 5; i++ {
require.Equal(t, http.StatusUnauthorized, login(fmt.Sprintf("10.2.2.%d", i), rlUser, "wrong").Code)
}
assertLimited(t, login("10.2.3.1", rlUser, "wrong"), 60)
assertLimited(t, login("10.2.3.2", rlUser, rlPassword), 60)
require.Equal(t, http.StatusUnauthorized, login("10.2.3.3", "another-user", "wrong").Code)
})
t.Run("unknown username is limited like an existing one", func(t *testing.T) {
resetLimits()
for i := 0; i < 5; i++ {
require.Equal(t, http.StatusUnauthorized, login(fmt.Sprintf("10.3.0.%d", i), "no-such-user", "wrong").Code)
}
assertLimited(t, login("10.3.1.1", "no-such-user", "wrong"), 60)
})
t.Run("per-IP login limit", func(t *testing.T) {
resetLimits()
for i := 0; i < 10; i++ {
require.Equal(t, http.StatusUnauthorized, login("10.4.0.1", fmt.Sprintf("user-%d", i), "wrong").Code)
}
assertLimited(t, login("10.4.0.1", rlUser, rlPassword), 60)
require.Equal(t, http.StatusOK, login("10.4.0.2", rlUser, rlPassword).Code)
})
t.Run("per-IP signup limit", func(t *testing.T) {
resetLimits()
for i := 0; i < 5; i++ {
require.Equal(t, http.StatusUnauthorized, signup("10.5.0.1", rlUser, "wrong").Code)
}
assertLimited(t, signup("10.5.0.1", rlUser, apiKey), 3600)
require.Equal(t, http.StatusUnauthorized, signup("10.5.0.2", rlUser, "wrong").Code)
})
t.Run("global signup limit", func(t *testing.T) {
resetLimits()
for i := 0; i < 20; i++ {
require.Equal(t, http.StatusUnauthorized, signup(fmt.Sprintf("10.6.0.%d", i), rlUser, "wrong").Code)
}
assertLimited(t, signup("10.6.1.1", rlUser, "wrong"), 3600)
})
}
func TestFailedSignUp(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/signup", func(c *gin.Context) {
handler.SignUp(c)
})
payload := `{"username": "admin", "password": "password", "api_key": "UHp3aKb40poKZluZByWQ"}`
req, err := http.NewRequest("POST", "/signup", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"wrong api key"}`
assert.Equal(t, expected, w.Body.String())
}
func TestSignUp(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/signup", func(c *gin.Context) {
handler.SignUp(c)
})
regKey := os.Getenv("API_KEY")
payload := fmt.Sprintf(`{"username": "admin", "password": "password", "api_key": "%s"}`, regKey)
req, err := http.NewRequest("POST", "/signup", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"result":"Successfully created admin user."}`
assert.Equal(t, expected, w.Body.String())
}
func TestSignUpSecondUser(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/signup", func(c *gin.Context) {
handler.SignUp(c)
})
regKey := os.Getenv("API_KEY")
payload := fmt.Sprintf(`{"username": "administrator", "password": "password", "api_key": "%s"}`, regKey)
req, err := http.NewRequest("POST", "/signup", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"result":"Successfully created admin user."}`
assert.Equal(t, expected, w.Body.String())
}
func TestFailedLogin(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/login", func(c *gin.Context) {
handler.Login(c)
})
// Create a JSON payload for the request
payload := `{"username": "admin", "password": "password1"}`
req, err := http.NewRequest("POST", "/login", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusUnauthorized, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"invalid username or password"}`
assert.Equal(t, expected, w.Body.String())
}
var authToken string
func TestLogin(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/login", func(c *gin.Context) {
handler.Login(c)
})
// Create a JSON payload for the request
payload := `{"username": "admin", "password": "password"}`
req, err := http.NewRequest("POST", "/login", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check that the "token" key exists in the response.
token, tokenExists := response["token"]
assert.True(t, tokenExists)
var ok bool
authToken, ok = token.(string)
require.True(t, ok, w.Body.String())
// Check that the authToken variable has been set (assuming authToken is a global variable).
assert.NotEmpty(t, authToken)
}
var authTokenSecondUser string
func TestLoginSecondUser(t *testing.T) {
router := gin.Default()
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/login", func(c *gin.Context) {
handler.Login(c)
})
// Create a JSON payload for the request
payload := `{"username": "administrator", "password": "password"}`
req, err := http.NewRequest("POST", "/login", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Parse the JSON response body to extract the token.
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check that the "token" key exists in the response.
token, tokenExists := response["token"]
assert.True(t, tokenExists)
var ok bool
authTokenSecondUser, ok = token.(string)
require.True(t, ok, w.Body.String())
// Check that the authTokenSecondUser variable has been set (assuming authTokenSecondUser is a global variable).
assert.NotEmpty(t, authTokenSecondUser)
}
func TestListApps(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /app/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/app/list", func(c *gin.Context) {
handler.ListApps(c)
})
// Create a POST request for the /app/list endpoint.
req, err := http.NewRequest("GET", "/app/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"apps":null}`
assert.Equal(t, expected, w.Body.String())
}
func TestListAppsWithInvalidToken(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/app/list", func(c *gin.Context) {
handler.ListApps(c)
})
tests := []struct {
name string
modifyFn func(token string) string
expectErr bool
}{
{
name: "Modified header",
modifyFn: func(token string) string {
parts := strings.Split(token, ".")
parts[0] = parts[0][:len(parts[0])-1] + "X" // Modify header
return strings.Join(parts, ".")
},
expectErr: true,
},
{
name: "Modified payload",
modifyFn: func(token string) string {
parts := strings.Split(token, ".")
parts[1] = parts[1][:len(parts[1])-1] + "Y" // Modify payload
return strings.Join(parts, ".")
},
expectErr: true,
},
{
name: "Modified signature",
modifyFn: func(token string) string {
parts := strings.Split(token, ".")
parts[2] = parts[2][:len(parts[2])-1] + "Z" // Modify signature
return strings.Join(parts, ".")
},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modifiedToken := tt.modifyFn(authToken)
logrus.Infof("Testing with token: %s", modifiedToken)
req, err := http.NewRequest("GET", "/app/list", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+modifiedToken)
router.ServeHTTP(w, req)
logrus.Infof("Status code for %s: %d", tt.name, w.Code)
logrus.Infof("Response body: %s", w.Body.String())
if tt.expectErr {
assert.Equal(t, http.StatusUnauthorized, w.Code)
} else {
assert.Equal(t, http.StatusOK, w.Code)
}
})
}
}
var idTestappApp string
var idPublicTestappApp string
func TestAppCreate(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /app/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/create", utils.CheckPermission(utils.PermissionCreate, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
handler.CreateApp(c)
})
// Create multipart/form-data request body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a field for the channel to the form
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app": "testapp", "private": "true", "tuf": "true", "reports": "true"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form data
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request to the /app/create endpoint
req, err := http.NewRequest("POST", "/app/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "createAppResult.Created" key in the response
id, idExists := response["createAppResult.Created"]
assert.True(t, idExists)
idTestappApp = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, idTestappApp)
}
var keyValueReportKeyTestapp string
func TestListReportKeys(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/report-keys/list", utils.CheckPermission(utils.PermissionEdit, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
handler.ListReportKeys(c)
})
req, err := http.NewRequest("GET", "/report-keys/list", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
type ReportKeyResponse struct {
ReportKeys []model.ReportKeyListItem `json:"report_keys"`
}
var actual ReportKeyResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
if !assert.Len(t, actual.ReportKeys, 1) {
return
}
item := actual.ReportKeys[0]
assert.Equal(t, "testapp", item.AppName)
assert.True(t, strings.HasPrefix(item.KeyValue, utils.ReportKeyPrefix))
assert.Len(t, item.KeyValue, len(utils.ReportKeyPrefix)+64)
keyValueReportKeyTestapp = item.KeyValue
}
func TestRegenerateReportKey(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/report-keys/regenerate", utils.CheckPermission(utils.PermissionEdit, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
handler.RegenerateReportKey(c)
})
payload := `{"app_id": "` + idTestappApp + `"}`
req, err := http.NewRequest("POST", "/report-keys/regenerate", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, idTestappApp, response["app_id"])
assert.NotEqual(t, keyValueReportKeyTestapp, response["key_value"].(string))
assert.True(t, strings.HasPrefix(response["key_value"].(string), utils.ReportKeyPrefix))
assert.Len(t, response["key_value"].(string), len(utils.ReportKeyPrefix)+64)
keyValueReportKeyTestapp = response["key_value"].(string)
}
func TestFailedRegenerateReportKeyWithSecondaryUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/report-keys/regenerate", utils.CheckPermission(utils.PermissionEdit, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
handler.RegenerateReportKey(c)
})
payload := `{"app_id": "` + idTestappApp + `"}`
req, err := http.NewRequest("POST", "/report-keys/regenerate", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "app_name not found in apps_meta collection", response["error"].(string))
}
func TestListReportKeysWithSecondaryUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/report-keys/list", utils.CheckPermission(utils.PermissionEdit, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
handler.ListReportKeys(c)
})
req, err := http.NewRequest("GET", "/report-keys/list", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
type ReportKeyResponse struct {
ReportKeys []model.ReportKeyListItem `json:"report_keys"`
}
var actual ReportKeyResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
if !assert.Len(t, actual.ReportKeys, 0) {
t.Fatal("Expected 0 report keys")
}
}
func TestCreatePublicApp(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /app/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/create", func(c *gin.Context) {
handler.CreateApp(c)
})
// Create multipart/form-data request body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a field for the channel to the form
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app": "public testapp", "reports": "true", "cdn": "true"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form data
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request to the /app/create endpoint
req, err := http.NewRequest("POST", "/app/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "createAppResult.Created" key in the response
idPublic, idExists := response["createAppResult.Created"]
assert.True(t, idExists)
idPublicTestappApp = idPublic.(string)
assert.True(t, idExists)
assert.NotEmpty(t, idTestappApp)
}
var idTestappAppWithUpdaters string
func TestCreateAppWithUpdaters(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /app/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/create", func(c *gin.Context) {
handler.CreateApp(c)
})
// Create multipart/form-data request body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a field for the channel to the form
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app": "updaters"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form data
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request to the /app/create endpoint
req, err := http.NewRequest("POST", "/app/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "createAppResult.Created" key in the response
id, idExists := response["createAppResult.Created"]
assert.True(t, idExists)
idTestappAppWithUpdaters = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, idTestappAppWithUpdaters)
}
func TestSecondaryAppCreate(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /app/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/create", func(c *gin.Context) {
handler.CreateApp(c)
})
// Create multipart/form-data request body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a field for the channel to the form
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app": "testapp"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form data
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request to the /app/create endpoint
req, err := http.NewRequest("POST", "/app/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for multipart/form-data
req.Header.Set("Content-Type", writer.FormDataContentType())
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusInternalServerError, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"app with this name already exists"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
var uploadedFirstApp string
func TestUpload(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePath := "LICENSE"
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Create a multipart/form-data request with the file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app_name": "testapp", "version": "0.0.1.137"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the /upload endpoint.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
logrus.Infof("Response Body after unmarshaling: %+v", response)
// Check that the "token" key exists in the response.
id, idExists := response["uploadResult.Uploaded"]
assert.True(t, idExists)
uploadedFirstApp = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, uploadedFirstApp)
}
func TestUploadDuplicateApp(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePath := "LICENSE"
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Create a multipart/form-data request with the file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app_name": "testapp", "version": "0.0.1.137"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the /upload endpoint.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code (expecting 409).
assert.Equal(t, http.StatusConflict, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"app with this name, version, platform, architecture and extension already exists"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
func TestDeleteApp(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
// Create a POST request for the /apps/delete endpoint.
req, err := http.NewRequest("DELETE", "/apps/delete?id="+uploadedFirstApp, nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"deleteSpecificAppResult.DeletedCount":1}`
assert.Equal(t, expected, w.Body.String())
}
func TestListChannels(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /channel/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/channel/list", func(c *gin.Context) {
handler.ListChannels(c)
})
// Create a POST request for the /channel/list endpoint.
req, err := http.NewRequest("GET", "/channel/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"channels":null}`
assert.Equal(t, expected, w.Body.String())
}
var idNightlyChannel string
var idStableChannel string
func TestChannelCreateNightly(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /channel/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/channel/create", func(c *gin.Context) {
handler.CreateChannel(c)
})
payload := `{
"channel": "nightly"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/channel/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "createChannelResult.Created" key in the response
id, idExists := response["createChannelResult.Created"]
assert.True(t, idExists)
idNightlyChannel = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, idNightlyChannel)
}
func TestChannelCreateWithWrongName(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /channel/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/channel/create", func(c *gin.Context) {
handler.CreateChannel(c)
})
payload := `{
"channel": "nightly *"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/channel/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 400).
assert.Equal(t, http.StatusBadRequest, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"invalid channel name"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
func TestSecondaryChannelCreateNightly(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /channel/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/channel/create", func(c *gin.Context) {
handler.CreateChannel(c)
})
payload := `{
"channel": "nightly"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/channel/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusInternalServerError, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"channel with this name already exists"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
func TestChannelCreateStable(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /channel/create route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/channel/create", func(c *gin.Context) {
handler.CreateChannel(c)
})
payload := `{
"channel": "stable"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/channel/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "createChannelResult.Created" key in the response
id, idExists := response["createChannelResult.Created"]
assert.True(t, idExists)
idStableChannel = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, idStableChannel)
}
func TestUploadAppWithoutChannel(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePath := "LICENSE"
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Create a multipart/form-data request with the file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app_name": "testapp", "version": "0.0.1.137"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the upload endpoint.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusBadRequest, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"you have a created channels, setting channel is required"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
func TestListPlatforms(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /platform/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/platform/list", func(c *gin.Context) {
handler.ListPlatforms(c)
})
// Create a POST request for the /platform/list endpoint.
req, err := http.NewRequest("GET", "/platform/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"platforms":null}`
assert.Equal(t, expected, w.Body.String())
}
var platformId string
func TestPlatformCreate(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "universalPlatform"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
platformId = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, platformId)
}
var platformIdWindows string
func TestPlatformCreateWindows(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "windows"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
platformIdWindows = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, platformIdWindows)
}
func TestUpdatePlatformWindows(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /platform/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/update", func(c *gin.Context) {
handler.UpdatePlatform(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"platform":"windows",
"updaters": [
{ "type": "manual", "default": false },
{ "type": "squirrel_windows", "default": true },
{ "type": "electron-builder", "default": false }
]
}`, platformIdWindows)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/update endpoint
req, err := http.NewRequest("POST", "/platform/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "updateChannelResult.Updated" key in the response
updated, exists := response["updatePlatformResult.Updated"]
assert.True(t, exists)
assert.True(t, updated.(bool))
}
var platformIdMacos string
func TestPlatformCreateMacos(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "macos"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
platformIdMacos = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, platformIdMacos)
}
var platformIdMacosTauri string
func TestPlatformCreateMacosTauri(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "macosTauri",
"updaters": [
{ "type": "manual", "default": false },
{ "type": "tauri", "default": true }
]
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
platformIdMacosTauri = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, platformIdMacosTauri)
}
func TestUpdatePlatformMacos(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /platform/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/update", func(c *gin.Context) {
handler.UpdatePlatform(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"platform":"macos",
"updaters": [
{ "type": "manual", "default": false },
{ "type": "squirrel_darwin", "default": true },
{ "type": "electron-builder", "default": false },
{ "type": "tauri", "default": false }
]
}`, platformIdMacos)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/update endpoint
req, err := http.NewRequest("POST", "/platform/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "updateChannelResult.Updated" key in the response
updated, exists := response["updatePlatformResult.Updated"]
assert.True(t, exists)
assert.True(t, updated.(bool))
}
var platformIdMacosSquirrel string
func TestPlatformCreateMacosSquirrel(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "macosSquirrel"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
platformIdMacosSquirrel = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, platformIdMacosSquirrel)
}
func TestUpdatePlatformMacosSquirrel(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /platform/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/update", func(c *gin.Context) {
handler.UpdatePlatform(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"platform":"macosSquirrel",
"updaters": [
{ "type": "manual", "default": false },
{ "type": "squirrel_darwin", "default": true },
{ "type": "electron-builder", "default": false }
]
}`, platformIdMacosSquirrel)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/update endpoint
req, err := http.NewRequest("POST", "/platform/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check for the presence of the "updateChannelResult.Updated" key in the response
updated, exists := response["updatePlatformResult.Updated"]
assert.True(t, exists)
assert.True(t, updated.(bool))
}
func TestSecondaryPlatformCreate(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "universalPlatform"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusInternalServerError, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"platform with this name already exists"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
var secondPlatformId string
func TestCreateSecondPlatform(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/create", func(c *gin.Context) {
handler.CreatePlatform(c)
})
payload := `{
"platform": "secondPlatform"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/create endpoint
req, err := http.NewRequest("POST", "/platform/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createPlatformResult.Created"]
assert.True(t, idExists)
secondPlatformId = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, secondPlatformId)
}
func TestUploadAppWithoutPlatform(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePath := "LICENSE"
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Create a multipart/form-data request with the file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app_name": "testapp", "version": "0.0.1.137", "channel": "nightly"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the upload endpoint.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusBadRequest, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"you have a created platforms, setting platform is required"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
func TestListArchs(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /arch/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/arch/list", func(c *gin.Context) {
handler.ListArchs(c)
})
// Create a POST request for the /arch/list endpoint.
req, err := http.NewRequest("GET", "/arch/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"archs":null}`
assert.Equal(t, expected, w.Body.String())
}
var archId string
func TestArchCreate(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/arch/create", func(c *gin.Context) {
handler.CreateArch(c)
})
payload := `{
"arch": "universalArch"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/arch/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createArchResult.Created"]
assert.True(t, idExists)
archId = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, archId)
}
func TestSecondaryArchCreate(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/arch/create", func(c *gin.Context) {
handler.CreateArch(c)
})
payload := `{
"arch": "universalArch"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/arch/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusInternalServerError, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"arch with this name already exists"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
var secondArchId string
func TestCreateSecondArch(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/arch/create", func(c *gin.Context) {
handler.CreateArch(c)
})
payload := `{
"arch": "secondArch"
}`
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/create endpoint
req, err := http.NewRequest("POST", "/arch/create", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusOK {
t.Fatalf("expected status %d; got %d", http.StatusOK, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["createArchResult.Created"]
assert.True(t, idExists)
secondArchId = id.(string)
assert.True(t, idExists)
assert.NotEmpty(t, secondArchId)
}
func TestUploadAppWithoutArch(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePath := "LICENSE"
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Create a multipart/form-data request with the file.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := `{"app_name": "testapp", "version": "0.0.1.137", "channel": "nightly", "platform": "universalPlatform"}`
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the upload endpoint.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code (expecting 500).
assert.Equal(t, http.StatusBadRequest, w.Code)
// Check the response body for the desired error message.
expectedErrorMessage := `{"error":"you have a created archs, setting arch is required"}`
assert.Equal(t, expectedErrorMessage, w.Body.String())
}
var uploadedAppIDs []string
func TestMultipleUpload(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePaths := []string{"testapp.dmg", "testapp.pkg", "LICENSE"}
for _, filePath := range filePaths {
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
combinations := []struct {
AppName string
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
}{
{"public testapp", "0.0.1.137", "nightly", true, false, "universalPlatform", "universalArch"},
{"testapp", "0.0.2.137", "nightly", true, false, "universalPlatform", "universalArch"},
{"testapp", "0.0.3.137", "nightly", false, false, "universalPlatform", "universalArch"},
{"testapp", "0.0.4.137", "stable", true, true, "universalPlatform", "universalArch"},
{"testapp", "0.0.5.137", "stable", false, false, "universalPlatform", "universalArch"},
}
// Iterate through the combinations and upload the file for each combination.
for _, combo := range combinations {
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"app_name": "%s", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s"}`, combo.AppName, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the /upload endpoint with the current combination.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["uploadResult.Uploaded"]
assert.True(t, idExists)
// Check if the id already exists in the uploadedAppIDs array
exists := false
for _, val := range uploadedAppIDs {
if val == id {
exists = true
break
}
}
// If id does not exist in the array, append it
if !exists {
uploadedAppIDs = append(uploadedAppIDs, id.(string))
}
assert.True(t, idExists)
assert.NotEmpty(t, id.(string))
}
}
}
var uploadedAppIDsWithUpdaters []string
func TestMultipleUploadWithUpdaters(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
combinations := []struct {
AppName string
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
Updater string
FileName string
Signature string
}{
{"updaters", "0.0.1.137", "nightly", false, false, "macosSquirrel", "universalArch", "squirrel_darwin", "testapp.dmg", ""},
{"updaters", "0.0.2.137", "nightly", true, false, "macosSquirrel", "universalArch", "squirrel_darwin", "test.zip", ""},
{"updaters", "0.0.1.137", "nightly", false, false, "macosTauri", "universalArch", "tauri", "test.zip", ""},
{"updaters", "0.0.2.137", "nightly", true, false, "macosTauri", "universalArch", "tauri", "test.zip", "dW50cnVzdGVkU2lnbmF0dXJlVGhhdExvb2tzTGlrZUFsb25nU3RyaW5n=="},
{"updaters", "0.0.3.137", "nightly", false, false, "macos", "universalArch", "electron-builder", "testapp.dmg", ""},
{"updaters", "0.0.4.137", "nightly", true, false, "macos", "universalArch", "electron-builder", "latest-mac.yml", ""},
{"updaters", "0.0.5.137", "stable", false, true, "windows", "universalArch", "squirrel_windows", "test.exe", ""},
{"updaters", "0.0.6.137", "stable", true, false, "windows", "universalArch", "squirrel_windows", "RELEASES", ""},
{"updaters", "0.0.7.137", "stable", false, false, "windows", "universalArch", "electron-builder", "test.exe", ""},
{"updaters", "0.0.8.137", "stable", true, false, "windows", "universalArch", "electron-builder", "latest.yml", ""},
}
// Iterate through the combinations and upload the file for each combination.
for _, combo := range combinations {
logrus.Infoln("Uploading this combo:", combo)
filePath := combo.FileName
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"app_name": "%s", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s", "updater": "%s", "signature": "%s"}`, combo.AppName, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch, combo.Updater, combo.Signature)
logrus.Infoln("Payload: ", payload)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request for the /upload endpoint with the current combination.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check if this combination should expect an error
shouldExpectError := (combo.AppVersion == "0.0.1.137" && combo.FileName == "testapp.dmg") ||
(combo.AppVersion == "0.0.3.137" && combo.FileName == "testapp.dmg") ||
(combo.AppVersion == "0.0.5.137" && combo.FileName == "test.exe") ||
(combo.AppVersion == "0.0.7.137" && combo.FileName == "test.exe") ||
(combo.AppVersion == "0.0.1.137" && combo.FileName == "test.zip")
if shouldExpectError {
// Expect 400 status code for error cases
assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
// Check that there's an error message in the response
errorMsg, hasError := response["error"]
assert.True(t, hasError, "Expected error message in response")
assert.NotEmpty(t, errorMsg, "Error message should not be empty")
// Check specific error messages based on updater type
var expectedErrorMsg string
switch combo.Updater {
case "squirrel_windows":
expectedErrorMsg = "squirrel windows updater requires a RELEASES file for update functionality. Please include a RELEASES file in your upload"
case "electron-builder":
expectedErrorMsg = "electron-builder updater requires a YML/YAML file for update functionality. Please include a .yml or .yaml file in your upload"
case "squirrel_darwin":
expectedErrorMsg = "squirrel darwin updater requires a ZIP archive for update functionality. Please include a ZIP file in your upload"
case "tauri":
expectedErrorMsg = "tauri updater requires a signature parameter for update functionality. Please include a signature in your request"
}
if expectedErrorMsg != "" {
assert.Equal(t, expectedErrorMsg, errorMsg, "Expected specific error message for %s updater", combo.Updater)
}
} else {
// Check the response status code for successful cases
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["uploadResult.Uploaded"]
assert.True(t, idExists)
// Check if the id already exists in the uploadedAppIDsWithUpdaters array
exists := false
for _, val := range uploadedAppIDsWithUpdaters {
if val == id {
exists = true
break
}
}
// If id does not exist in the array, append it
if !exists {
uploadedAppIDsWithUpdaters = append(uploadedAppIDsWithUpdaters, id.(string))
}
assert.True(t, idExists)
assert.NotEmpty(t, id.(string))
}
}
}
func TestCheckVersionWithUpdaters(t *testing.T) {
router := gin.Default()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/checkVersion", func(c *gin.Context) {
handler.FindLatestVersion(c)
})
// Define test scenarios.
testScenarios := []struct {
AppName string
Version string
ChannelName string
ExpectedJSON map[string]interface{}
ExpectedCode int
Published bool
Platform string
Arch string
TestName string
Owner string
Updater string
ExpectedBody string
}{
{
AppName: "updaters",
Version: "0.0.2.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{"status": "no_content"},
ExpectedCode: http.StatusNoContent,
Platform: "macosSquirrel",
Arch: "universalArch",
TestName: "SquirrelDarwinUpdateNotAvailable",
Owner: "admin",
Updater: "squirrel_darwin",
},
{
AppName: "updaters",
Version: "0.0.1.135",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"critical": false,
"update_available": true,
"url": fmt.Sprintf("%s/%s", s3Endpoint, "updaters-admin/nightly/macosSquirrel/universalArch/updaters-0.0.2.137.zip"),
},
ExpectedCode: http.StatusOK,
Platform: "macosSquirrel",
Arch: "universalArch",
TestName: "SquirrelDarwinUpdateAvailable",
Owner: "admin",
Updater: "squirrel_darwin",
},
{
AppName: "updaters",
Version: "0.0.2.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{"status": "no_content"},
ExpectedCode: http.StatusNoContent,
Platform: "macosTauri",
Arch: "universalArch",
TestName: "TauriUpdateNotAvailable",
Owner: "admin",
Updater: "tauri",
},
{
AppName: "updaters",
Version: "0.0.1.135",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"signature": "dW50cnVzdGVkU2lnbmF0dXJlVGhhdExvb2tzTGlrZUFsb25nU3RyaW5n==",
"version": "0.0.2.137",
"url": fmt.Sprintf("%s/%s", s3Endpoint, "updaters-admin/nightly/macosTauri/universalArch/updaters-0.0.2.137.zip"),
},
ExpectedCode: http.StatusOK,
Platform: "macosTauri",
Arch: "universalArch",
TestName: "TauriUpdateAvailable",
Owner: "admin",
Updater: "tauri",
},
{
AppName: "updaters",
Version: "0.0.4.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{"status": "no_content"},
ExpectedCode: http.StatusNoContent,
// Published: false,
Platform: "macos",
Arch: "universalArch",
TestName: "ElectronBuilderMacosUpdateNotAvailable",
Owner: "admin",
Updater: "electron-builder",
},
{
AppName: "updaters",
Version: "0.0.1.133",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"critical": false,
"update_available": true,
"update_url_yml": fmt.Sprintf("%s/%s", s3Endpoint, "electron-builder/updaters-admin/0.0.4.137/nightly/macos/universalArch/latest-mac.yml"),
},
ExpectedCode: http.StatusFound,
// Published: false,
Platform: "macos",
Arch: "universalArch",
TestName: "ElectronBuilderMacosUpdateAvailable",
Owner: "admin",
Updater: "electron-builder",
ExpectedBody: "latest file for macos electron builder",
},
{
AppName: "updaters",
Version: "0.0.8.137",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{"status": "no_content"},
ExpectedCode: http.StatusNoContent,
// Published: false,
Platform: "windows",
Arch: "universalArch",
TestName: "ElectronBuilderWindowsUpdateNotAvailable",
Owner: "admin",
Updater: "electron-builder",
},
{
AppName: "updaters",
Version: "0.0.1.131",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"status": "redirect",
"url": fmt.Sprintf("%s/%s", s3Endpoint, "electron-builder/updaters-admin/0.0.8.137/stable/windows/universalArch/latest.yml"),
},
ExpectedCode: http.StatusFound,
// Published: false,
Platform: "windows",
Arch: "universalArch",
TestName: "ElectronBuilderWindowsUpdateAvailable",
Owner: "admin",
Updater: "electron-builder",
ExpectedBody: "latest file for windows electron-builder",
},
}
for _, scenario := range testScenarios {
t.Run(scenario.TestName, func(t *testing.T) {
w := httptest.NewRecorder()
// Create a GET request for checking the version.
req, err := http.NewRequest("GET", fmt.Sprintf("/checkVersion?app_name=%s&version=%s&channel=%s&platform=%s&arch=%s&owner=%s&updater=%s", scenario.AppName, scenario.Version, scenario.ChannelName, scenario.Platform, scenario.Arch, scenario.Owner, scenario.Updater), nil)
if err != nil {
t.Fatal(err)
}
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, scenario.ExpectedCode, w.Code)
fmt.Println("Code: , Response: ", w.Code, w.Body.String())
switch scenario.ExpectedCode {
case http.StatusNoContent:
// For 204, we expect either empty body or {"status": "no_content"}
if len(w.Body.Bytes()) == 0 {
// Empty body is fine for 204
return
}
fmt.Println("Content: ", w.Body.String())
// If there's content, it should be {"status": "no_content"}
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
expectedNoContent := map[string]interface{}{"status": "no_content"}
assert.Equal(t, expectedNoContent, actual)
case http.StatusFound:
// For 302 Found (redirect), check the Location header and HTML body
locationHeader := w.Header().Get("Location")
logrus.Infof("Location Header: %s", locationHeader)
if locationHeader != "" {
// If Location header is set, that's the redirect URL
var expectedURL string
if url, exists := scenario.ExpectedJSON["url"]; exists {
expectedURL = url.(string)
} else if updateURL, exists := scenario.ExpectedJSON["update_url_yml"]; exists {
expectedURL = updateURL.(string)
} else {
t.Fatal("Neither 'url' nor 'update_url_yml' found in ExpectedJSON")
}
assert.Equal(t, expectedURL, locationHeader)
body := checkFileContent(t, locationHeader)
logrus.Infof("asserting that body: %s, contains: %s", body, scenario.ExpectedBody)
assert.Contains(t, body, scenario.ExpectedBody)
} else {
// If no Location header, check HTML body for redirect link
body := w.Body.String()
expectedURL := scenario.ExpectedJSON["url"].(string)
// Check if the expected URL is present in the HTML body
assert.Contains(t, body, expectedURL)
}
default:
// For other status codes, parse and compare JSON
var actual map[string]interface{}
logrus.Infoln("Body: ", w.Body.String())
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the response with the expected values.
assert.Equal(t, scenario.ExpectedJSON, actual)
}
})
}
}
// ============================================================================
// Sparkle & Velopack full interactive flow (self-contained block).
// Two dedicated apps, 5 versions each, mixed publish/critical/changelog across
// two platforms, publish/unpublish toggles, and materialized-feed verification
// on S3 — including per-platform/per-channel isolation (regression for the
// multi-platform materialization bug). Cleans up its own app + version docs.
// Reuses channels (nightly/stable), platforms (macos/macosSquirrel/windows) and
// arch (universalArch) created earlier in the ordered suite.
// ============================================================================
type updaterFeedFile struct {
name string
content []byte
}
func updaterFlowRouter() *gin.Engine {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
h := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/create", func(c *gin.Context) { h.CreateApp(c) })
router.POST("/upload", func(c *gin.Context) { h.UploadApp(c) })
router.POST("/upload/init", func(c *gin.Context) { h.InitPresignedUpload(c) })
router.POST("/upload/complete", func(c *gin.Context) { h.CompletePresignedUpload(c) })
router.POST("/apps/update", func(c *gin.Context) { h.UpdateSpecificApp(c) })
router.DELETE("/apps/delete", func(c *gin.Context) { h.DeleteSpecificVersionOfApp(c) })
router.DELETE("/app/delete", func(c *gin.Context) { h.DeleteApp(c) })
return router
}
func doUpdaterRequest(t *testing.T, router *gin.Engine, path, payload string, files []updaterFeedFile) *httptest.ResponseRecorder {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, f := range files {
part, err := writer.CreateFormFile("file", f.name)
require.NoError(t, err)
_, err = part.Write(f.content)
require.NoError(t, err)
}
dataPart, err := writer.CreateFormField("data")
require.NoError(t, err)
_, err = dataPart.Write([]byte(payload))
require.NoError(t, err)
require.NoError(t, writer.Close())
req, err := http.NewRequest("POST", path, body)
require.NoError(t, err)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// cleanupUpdaterApp removes the app through the delete API, so the feed files and
// artifacts it materialized in storage are erased the same way a real delete does.
func cleanupUpdaterApp(t *testing.T, appName string) {
t.Helper()
ctx := context.Background()
var meta struct {
ID primitive.ObjectID `bson:"_id"`
}
if err := mongoDatabase.Collection("apps_meta").FindOne(ctx, bson.M{"app_name": appName, "owner": "admin"}).Decode(&meta); err != nil {
return
}
cursor, err := mongoDatabase.Collection("apps").Find(ctx, bson.M{"app_id": meta.ID})
require.NoError(t, err)
var versions []struct {
ID primitive.ObjectID `bson:"_id"`
}
require.NoError(t, cursor.All(ctx, &versions))
router := updaterFlowRouter()
for _, v := range versions {
w := doUpdaterDelete(t, router, "/apps/delete?id="+v.ID.Hex())
require.Equal(t, http.StatusOK, w.Code, "delete version %s: %s", v.ID.Hex(), w.Body.String())
}
w := doUpdaterDelete(t, router, "/app/delete?id="+meta.ID.Hex())
require.Equal(t, http.StatusOK, w.Code, "delete app %s: %s", appName, w.Body.String())
}
func doUpdaterDelete(t *testing.T, router *gin.Engine, path string) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequest("DELETE", path, nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func createUpdaterApp(t *testing.T, router *gin.Engine, appName string) {
t.Helper()
w := doUpdaterRequest(t, router, "/app/create", fmt.Sprintf(`{"app": "%s"}`, appName), nil)
require.Equal(t, http.StatusOK, w.Code, "create %s: %s", appName, w.Body.String())
}
func parseAppcastItems(t *testing.T, feed string) map[string]*etree.Element {
t.Helper()
doc := etree.NewDocument()
require.NoError(t, doc.ReadFromString(feed), "feed is not valid xml:\n%s", feed)
out := map[string]*etree.Element{}
for _, it := range doc.FindElements("//item") {
if v := it.SelectElement("sparkle:version"); v != nil {
out[strings.TrimSpace(v.Text())] = it
}
}
return out
}
func TestSparkleUpdaterFlow(t *testing.T) {
cleanupUpdaterApp(t, "sparkleapp")
defer cleanupUpdaterApp(t, "sparkleapp")
router := updaterFlowRouter()
createUpdaterApp(t, router, "sparkleapp")
appcast := func(zip, sv string) []byte {
return []byte(fmt.Sprintf(`<?xml version="1.0" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"><channel><title>sparkleapp</title>
<item><title>%s</title><sparkle:version>%s</sparkle:version><sparkle:shortVersionString>%s</sparkle:shortVersionString>
<enclosure url="http://orig/%s" length="100" type="application/octet-stream" sparkle:edSignature="SIG=="/></item></channel></rss>`, sv, sv, sv, zip))
}
versions := []struct {
version, channel, platform, sparkleVer, changelog string
publish, critical bool
}{
{"0.0.1", "nightly", "macos", "1", "Initial release", true, false},
{"0.0.2", "nightly", "macos", "2", "Security fix critical", true, true},
{"0.0.3", "nightly", "macos", "3", "WIP not ready", false, false},
{"0.0.4", "stable", "macos", "4", "Stable build", true, false},
{"0.0.5", "nightly", "macosSquirrel", "5", "Other platform build", true, false},
}
ids := map[string]string{}
for _, v := range versions {
zip := fmt.Sprintf("sparkleapp-%s.zip", v.version)
files := []updaterFeedFile{
{name: "appcast." + v.channel + ".xml", content: appcast(zip, v.sparkleVer)},
{name: zip, content: []byte("fake zip payload")},
}
payload := fmt.Sprintf(`{"app_name":"sparkleapp","version":"%s","channel":"%s","publish":%v,"critical":%v,"platform":"%s","arch":"universalArch","updater":"sparkle","changelog":"%s"}`,
v.version, v.channel, v.publish, v.critical, v.platform, v.changelog)
w := doUpdaterRequest(t, router, "/upload", payload, files)
require.Equal(t, http.StatusOK, w.Code, "upload %s: %s", v.version, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
id, _ := resp["uploadResult.Uploaded"].(string)
require.NotEmpty(t, id, "no upload id for %s", v.version)
ids[v.version] = id
}
nightlyMacURL := fmt.Sprintf("%s/sparkle/admin/sparkleapp/macos/universalArch/appcast.nightly.xml", s3Endpoint)
t.Run("nightly_macos_feed", func(t *testing.T) {
items := parseAppcastItems(t, checkFileContent(t, nightlyMacURL))
require.Contains(t, items, "1", "published v0.0.1 must be in feed")
require.Contains(t, items, "2", "published v0.0.2 must be in feed")
require.NotContains(t, items, "3", "unpublished v0.0.3 must be excluded")
require.NotContains(t, items, "5", "macosSquirrel v0.0.5 must not leak into macos feed")
assert.Nil(t, items["1"].SelectElement("sparkle:criticalUpdate"), "non-critical v0.0.1 must have no criticalUpdate")
assert.NotNil(t, items["2"].SelectElement("sparkle:criticalUpdate"), "critical v0.0.2 must carry criticalUpdate")
})
t.Run("changelog_rendered", func(t *testing.T) {
feed := checkFileContent(t, nightlyMacURL)
assert.Contains(t, feed, "Initial release")
assert.Contains(t, feed, "Security fix critical")
})
t.Run("channel_and_platform_isolation", func(t *testing.T) {
stable := parseAppcastItems(t, checkFileContent(t, fmt.Sprintf("%s/sparkle/admin/sparkleapp/macos/universalArch/appcast.stable.xml", s3Endpoint)))
require.Contains(t, stable, "4")
require.NotContains(t, stable, "1", "nightly build must not appear in stable feed")
other := parseAppcastItems(t, checkFileContent(t, fmt.Sprintf("%s/sparkle/admin/sparkleapp/macosSquirrel/universalArch/appcast.nightly.xml", s3Endpoint)))
require.Contains(t, other, "5")
require.NotContains(t, other, "1", "macos build must not appear in macosSquirrel feed")
require.NotContains(t, other, "2")
})
toggle := func(version, channel, platform string, publish bool) {
payload := fmt.Sprintf(`{"id":"%s","app_name":"sparkleapp","version":"%s","channel":"%s","publish":%v,"platform":"%s","arch":"universalArch"}`,
ids[version], version, channel, publish, platform)
w := doUpdaterRequest(t, router, "/apps/update", payload, nil)
require.Equal(t, http.StatusOK, w.Code, "toggle %s publish=%v: %s", version, publish, w.Body.String())
}
t.Run("unpublish_removes_from_feed", func(t *testing.T) {
toggle("0.0.2", "nightly", "macos", false)
items := parseAppcastItems(t, checkFileContent(t, nightlyMacURL))
require.NotContains(t, items, "2", "unpublished v0.0.2 must disappear from feed")
require.Contains(t, items, "1")
})
t.Run("publish_adds_to_feed", func(t *testing.T) {
toggle("0.0.3", "nightly", "macos", true)
items := parseAppcastItems(t, checkFileContent(t, nightlyMacURL))
require.Contains(t, items, "3", "newly published v0.0.3 must appear in feed")
})
// Regression for targeted regeneration: a single version with artifacts on
// TWO platforms. A version-level unpublish must clear it from BOTH platform
// feeds — proving targeted regen covers every tuple the version spans, not
// just the one in the request payload.
t.Run("multiplatform_version_unpublish_clears_all_feeds", func(t *testing.T) {
sqURL := fmt.Sprintf("%s/sparkle/admin/sparkleapp/macosSquirrel/universalArch/appcast.nightly.xml", s3Endpoint)
zipMac := "sparkleapp-0.0.9.zip"
wUp := doUpdaterRequest(t, router, "/upload",
`{"app_name":"sparkleapp","version":"0.0.9","channel":"nightly","publish":true,"critical":false,"platform":"macos","arch":"universalArch","updater":"sparkle","changelog":"multi platform"}`,
[]updaterFeedFile{{name: "appcast.nightly.xml", content: appcast(zipMac, "9")}, {name: zipMac, content: []byte("z")}})
require.Equal(t, http.StatusOK, wUp.Code, wUp.Body.String())
var upResp map[string]interface{}
require.NoError(t, json.Unmarshal(wUp.Body.Bytes(), &upResp))
id, _ := upResp["uploadResult.Uploaded"].(string)
require.NotEmpty(t, id)
// add a second platform (macosSquirrel) to the SAME version via update
zipSq := "sparkleapp-0.0.9-sq.zip"
wAdd := doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"sparkleapp","version":"0.0.9","channel":"nightly","publish":true,"critical":false,"platform":"macosSquirrel","arch":"universalArch","updater":"sparkle","changelog":"multi platform"}`, id),
[]updaterFeedFile{{name: "appcast.nightly.xml", content: appcast(zipSq, "9")}, {name: zipSq, content: []byte("z")}})
require.Equal(t, http.StatusOK, wAdd.Code, wAdd.Body.String())
require.Contains(t, parseAppcastItems(t, checkFileContent(t, nightlyMacURL)), "9", "v0.0.9 must be in macos feed")
require.Contains(t, parseAppcastItems(t, checkFileContent(t, sqURL)), "9", "v0.0.9 must be in macosSquirrel feed")
// version-level unpublish (payload names only macos) must still clear BOTH
wUnpub := doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"sparkleapp","version":"0.0.9","channel":"nightly","publish":false,"platform":"macos","arch":"universalArch"}`, id),
nil)
require.Equal(t, http.StatusOK, wUnpub.Code, wUnpub.Body.String())
require.NotContains(t, parseAppcastItems(t, checkFileContent(t, nightlyMacURL)), "9", "unpublish must remove v0.0.9 from macos feed")
require.NotContains(t, parseAppcastItems(t, checkFileContent(t, sqURL)), "9", "unpublish must remove v0.0.9 from macosSquirrel feed (all tuples)")
})
}
type velopackFeedAsset struct {
Version string
Type string
FileName string
NotesHTML string
}
type velopackFeedDoc struct {
Assets []velopackFeedAsset
}
func parseVelopackFeed(t *testing.T, feed string) map[string]velopackFeedAsset {
t.Helper()
var doc velopackFeedDoc
require.NoError(t, json.Unmarshal([]byte(feed), &doc), "feed is not valid json:\n%s", feed)
out := map[string]velopackFeedAsset{}
for _, a := range doc.Assets {
if a.Type == "Full" {
out[a.Version] = a
}
}
return out
}
func TestVelopackUpdaterFlow(t *testing.T) {
cleanupUpdaterApp(t, "velopackapp")
defer cleanupUpdaterApp(t, "velopackapp")
router := updaterFlowRouter()
createUpdaterApp(t, router, "velopackapp")
releases := func(nupkg string) []byte {
return []byte(fmt.Sprintf(`{"Assets":[{"Type":"Full","FileName":"%s","SHA1":"a","SHA256":"b","Size":100}]}`, nupkg))
}
versions := []struct {
version, channel, platform, changelog string
publish bool
}{
{"1.0.0", "nightly", "windows", "First release", true},
{"1.0.1", "nightly", "windows", "Second release notes", true},
{"1.0.2", "nightly", "windows", "Unpublished wip", false},
{"1.0.3", "stable", "windows", "Stable build", true},
{"1.0.4", "nightly", "macos", "Mac build", true},
}
ids := map[string]string{}
for _, v := range versions {
nupkg := fmt.Sprintf("velopackapp-%s-full.nupkg", v.version)
files := []updaterFeedFile{
{name: "releases." + v.channel + ".json", content: releases(nupkg)},
{name: nupkg, content: []byte("fake nupkg payload")},
}
payload := fmt.Sprintf(`{"app_name":"velopackapp","version":"%s","channel":"%s","publish":%v,"critical":false,"platform":"%s","arch":"universalArch","updater":"velopack","changelog":"%s"}`,
v.version, v.channel, v.publish, v.platform, v.changelog)
w := doUpdaterRequest(t, router, "/upload", payload, files)
require.Equal(t, http.StatusOK, w.Code, "upload %s: %s", v.version, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
id, _ := resp["uploadResult.Uploaded"].(string)
require.NotEmpty(t, id, "no upload id for %s", v.version)
ids[v.version] = id
}
nightlyWinURL := fmt.Sprintf("%s/velopack/admin/velopackapp/windows/universalArch/releases.nightly.json", s3Endpoint)
t.Run("nightly_windows_feed", func(t *testing.T) {
assets := parseVelopackFeed(t, checkFileContent(t, nightlyWinURL))
require.Contains(t, assets, "1.0.0", "published 1.0.0 must be in feed")
require.Contains(t, assets, "1.0.1", "published 1.0.1 must be in feed")
require.NotContains(t, assets, "1.0.2", "unpublished 1.0.2 must be excluded")
require.NotContains(t, assets, "1.0.4", "macos 1.0.4 must not leak into windows feed")
assert.Contains(t, assets["1.0.1"].NotesHTML, "Second release notes", "changelog must render into NotesHTML")
})
t.Run("channel_and_platform_isolation", func(t *testing.T) {
stable := parseVelopackFeed(t, checkFileContent(t, fmt.Sprintf("%s/velopack/admin/velopackapp/windows/universalArch/releases.stable.json", s3Endpoint)))
require.Contains(t, stable, "1.0.3")
require.NotContains(t, stable, "1.0.0", "nightly build must not appear in stable feed")
mac := parseVelopackFeed(t, checkFileContent(t, fmt.Sprintf("%s/velopack/admin/velopackapp/macos/universalArch/releases.nightly.json", s3Endpoint)))
require.Contains(t, mac, "1.0.4")
require.NotContains(t, mac, "1.0.0", "windows build must not appear in macos feed")
})
toggle := func(version, channel, platform string, publish bool) {
payload := fmt.Sprintf(`{"id":"%s","app_name":"velopackapp","version":"%s","channel":"%s","publish":%v,"platform":"%s","arch":"universalArch"}`,
ids[version], version, channel, publish, platform)
w := doUpdaterRequest(t, router, "/apps/update", payload, nil)
require.Equal(t, http.StatusOK, w.Code, "toggle %s publish=%v: %s", version, publish, w.Body.String())
}
t.Run("unpublish_removes_from_feed", func(t *testing.T) {
toggle("1.0.0", "nightly", "windows", false)
assets := parseVelopackFeed(t, checkFileContent(t, nightlyWinURL))
require.NotContains(t, assets, "1.0.0", "unpublished 1.0.0 must disappear from feed")
require.Contains(t, assets, "1.0.1")
})
t.Run("publish_adds_to_feed", func(t *testing.T) {
toggle("1.0.2", "nightly", "windows", true)
assets := parseVelopackFeed(t, checkFileContent(t, nightlyWinURL))
require.Contains(t, assets, "1.0.2", "newly published 1.0.2 must appear in feed")
})
}
// ============================================================================
// /apps/latest download links and the Tauri update URL (self-contained block).
// Regression for: updater-internal files (feeds, .nupkg, .blockmap, .sig, Tauri
// bundles) leaking into /apps/latest and overwriting each other by extension;
// the newest version being picked per channel instead of per platform/arch;
// staged rollouts reaching download pages; compound extensions stored as ".gz";
// and the Tauri /checkVersion url picked in random map order. Reuses channel
// nightly, platforms windows/macos/macosTauri and arch universalArch created
// earlier in the ordered suite. Cleans up its own apps.
// ============================================================================
func uploadLatestFlowVersion(t *testing.T, router *gin.Engine, appName, version, platform, updater, extra string, names ...string) string {
t.Helper()
files := make([]updaterFeedFile, 0, len(names))
for _, name := range names {
content := []byte(name)
if name == "RELEASES" {
content = []byte(fmt.Sprintf("AAAA %s-%s-full.nupkg 10\nBBBB %s-%s-delta.nupkg 5", appName, version, appName, version))
}
files = append(files, updaterFeedFile{name: name, content: content})
}
payload := fmt.Sprintf(`{"app_name":"%s","version":"%s","channel":"nightly","publish":true,"platform":"%s","arch":"universalArch","updater":"%s"%s}`,
appName, version, platform, updater, extra)
w := doUpdaterRequest(t, router, "/upload", payload, files)
require.Equal(t, http.StatusOK, w.Code, "upload %s %s: %s", version, platform, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
id, _ := resp["uploadResult.Uploaded"].(string)
require.NotEmpty(t, id)
return id
}
func latestDownloadPath(appName, query string) string {
return fmt.Sprintf("/apps/latest?app_name=%s&channel=nightly&owner=admin&%s", appName, query)
}
func requireLatestRedirect(t *testing.T, appName, query, wantSuffix string) {
t.Helper()
w := serveVersionCheck(t, latestDownloadPath(appName, query), nil)
require.Equal(t, http.StatusFound, w.Code, "%s: %s", query, w.Body.String())
location := w.Header().Get("Location")
require.True(t, strings.HasSuffix(location, wantSuffix), "%s redirected to %q, want suffix %q", query, location, wantSuffix)
}
func requireLatestNotFound(t *testing.T, appName, query string) {
t.Helper()
w := serveVersionCheck(t, latestDownloadPath(appName, query), nil)
require.Equal(t, http.StatusNotFound, w.Code, "%s must not resolve: %s", query, w.Body.String())
}
// latestDownloadEntries returns package -> {url, version} per platform for arch universalArch.
func latestDownloadEntries(t *testing.T, appName, query string) map[string]map[string]map[string]string {
t.Helper()
w := serveVersionCheck(t, latestDownloadPath(appName, query), nil)
require.Equal(t, http.StatusOK, w.Code, "%s: %s", query, w.Body.String())
var body map[string]map[string]map[string]map[string]map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), w.Body.String())
out := map[string]map[string]map[string]string{}
for platform, archs := range body["nightly"] {
out[platform] = archs["universalArch"]
}
return out
}
func requireLatestPackages(t *testing.T, entries map[string]map[string]string, version string, packages ...string) {
t.Helper()
got := make([]string, 0, len(entries))
for pkg, entry := range entries {
got = append(got, pkg)
require.Equal(t, version, entry["version"], "package %s version", pkg)
}
require.ElementsMatch(t, packages, got, "entries: %v", entries)
}
func TestAppsLatestDownloadFlow(t *testing.T) {
const app = "latestdlapp"
cleanupUpdaterApp(t, app)
defer cleanupUpdaterApp(t, app)
router := updaterFlowRouter()
createUpdaterApp(t, router, app)
uploadLatestFlowVersion(t, router, app, "1.0.1", "windows", "squirrel_windows", "",
"RELEASES", app+"-1.0.1-full.nupkg", app+"-1.0.1-delta.nupkg", "Setup.exe")
uploadLatestFlowVersion(t, router, app, "1.0.1", "macos", "electron-builder", "",
"latest-mac.yml", app+"-1.0.1-mac.zip", app+"-1.0.1-mac.zip.blockmap", app+"-1.0.1.dmg")
t.Run("installers_only", func(t *testing.T) {
requireLatestRedirect(t, app, "platform=windows&arch=universalArch", "/Setup.exe")
requireLatestPackages(t, latestDownloadEntries(t, app, "platform=macos&arch=universalArch")["macos"], "1.0.1", "dmg", "zip")
requireLatestNotFound(t, app, "platform=windows&arch=universalArch&package=nupkg")
requireLatestNotFound(t, app, "platform=windows&arch=universalArch&package=no-extension")
requireLatestNotFound(t, app, "platform=macos&arch=universalArch&package=yml")
requireLatestNotFound(t, app, "platform=macos&arch=universalArch&package=blockmap")
})
t.Run("platform_missing_from_newest_release", func(t *testing.T) {
uploadLatestFlowVersion(t, router, app, "1.0.2", "windows", "manual", "", app+"-1.0.2.exe")
requireLatestRedirect(t, app, "platform=windows&arch=universalArch", app+"-1.0.2.exe")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=dmg", app+"-1.0.1.dmg")
all := latestDownloadEntries(t, app, "")
requireLatestPackages(t, all["windows"], "1.0.2", "exe")
requireLatestPackages(t, all["macos"], "1.0.1", "dmg", "zip")
})
t.Run("compound_extension_and_package_fallback", func(t *testing.T) {
uploadLatestFlowVersion(t, router, app, "1.0.3", "macos", "manual", "", app+"-1.0.3-arm64.tar.gz")
all := latestDownloadEntries(t, app, "")
requireLatestPackages(t, all["macos"], "1.0.3", "tar.gz")
require.True(t, strings.HasSuffix(all["macos"]["tar.gz"]["url"], app+"-1.0.3.tar.gz"), "stored as %s", all["macos"]["tar.gz"]["url"])
requireLatestPackages(t, all["windows"], "1.0.2", "exe")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch", app+"-1.0.3.tar.gz")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=tar.gz", app+"-1.0.3.tar.gz")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=dmg", app+"-1.0.1.dmg")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=zip", app+"-1.0.1-mac.zip")
requireLatestNotFound(t, app, "platform=macos&arch=universalArch&package=gz")
})
t.Run("staged_rollout_skipped_until_full", func(t *testing.T) {
id := uploadLatestFlowVersion(t, router, app, "1.0.4", "macos", "manual", `,"rollout":10`, app+"-1.0.4.dmg")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=dmg", app+"-1.0.1.dmg")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch", app+"-1.0.3.tar.gz")
requireLatestPackages(t, latestDownloadEntries(t, app, "")["macos"], "1.0.3", "tar.gz")
w := doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"%s","version":"1.0.4","channel":"nightly","publish":true,"platform":"macos","arch":"universalArch","rollout":100}`, id, app), nil)
require.Equal(t, http.StatusOK, w.Code, "rollout to 100: %s", w.Body.String())
requireLatestRedirect(t, app, "platform=macos&arch=universalArch&package=dmg", app+"-1.0.4.dmg")
requireLatestRedirect(t, app, "platform=macos&arch=universalArch", app+"-1.0.4.dmg")
requireLatestPackages(t, latestDownloadEntries(t, app, "")["macos"], "1.0.4", "dmg")
})
}
func TestTauriUpdateURLFlow(t *testing.T) {
const app = "tauriurlapp"
const signature = "dGF1cmktYnVuZGxlLXNpZ25hdHVyZQ=="
cleanupUpdaterApp(t, app)
defer cleanupUpdaterApp(t, app)
router := updaterFlowRouter()
createUpdaterApp(t, router, app)
uploadLatestFlowVersion(t, router, app, "1.0.1", "macosTauri", "tauri", fmt.Sprintf(`,"signature":"%s"`, signature),
app+".app.tar.gz", app+".app.tar.gz.sig", app+"_1.0.1_aarch64.dmg")
// Every request uses another client version, so each one misses the response cache
// and exercises the url pick again instead of replaying the first answer.
t.Run("checkversion_returns_bundle_every_time", func(t *testing.T) {
for i := 0; i < 25; i++ {
path := fmt.Sprintf("/checkVersion?app_name=%s&version=0.0.0.%d&channel=nightly&platform=macosTauri&arch=universalArch&owner=admin&updater=tauri", app, i)
w := serveVersionCheck(t, path, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
url, _ := resp["url"].(string)
require.True(t, strings.HasSuffix(url, app+"-1.0.1.app.tar.gz"), "request %d got url %q", i, url)
require.Equal(t, signature, resp["signature"])
require.Equal(t, "1.0.1", resp["version"])
}
})
t.Run("apps_latest_returns_installer", func(t *testing.T) {
requireLatestRedirect(t, app, "platform=macosTauri&arch=universalArch", app+"-1.0.1.dmg")
requireLatestNotFound(t, app, "platform=macosTauri&arch=universalArch&package=app.tar.gz")
requireLatestNotFound(t, app, "platform=macosTauri&arch=universalArch&package=sig")
})
}
func storageObjectStatus(t *testing.T, url string) int {
t.Helper()
if viper.GetString("STORAGE_DRIVER") == "gcp" {
url = fmt.Sprintf("%s?nocache=%d", url, time.Now().UnixNano())
}
resp, err := (&http.Client{Timeout: 10 * time.Second}).Get(url)
require.NoError(t, err)
resp.Body.Close()
return resp.StatusCode
}
func storedVersionPackages(t *testing.T, appName, version string) []string {
t.Helper()
ctx := context.Background()
var meta struct {
ID primitive.ObjectID `bson:"_id"`
}
require.NoError(t, mongoDatabase.Collection("apps_meta").FindOne(ctx, bson.M{"app_name": appName, "owner": "admin"}).Decode(&meta))
var doc model.SpecificApp
err := mongoDatabase.Collection("apps").FindOne(ctx, bson.M{"app_id": meta.ID, "version": version}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return nil
}
require.NoError(t, err)
packages := make([]string, 0, len(doc.Artifacts))
for _, artifact := range doc.Artifacts {
packages = append(packages, artifact.Package)
}
return packages
}
// Regression for the non-atomic multipart /upload: every file used to be written to
// storage before the duplicate check, so a rejected request overwrote stored files and
// left a partially recorded version (e.g. an electron-builder macOS build with two
// .blockmap files lost its latest-mac.yml). Conflicts must now be caught before any write.
func TestUploadRejectsBeforeWriting(t *testing.T) {
const app = "uploadatomicapp"
cleanupUpdaterApp(t, app)
defer cleanupUpdaterApp(t, app)
router := updaterFlowRouter()
createUpdaterApp(t, router, app)
defaultLayoutURL := func(version, ext string) string {
return fmt.Sprintf("%s/%s-admin/nightly/macos/universalArch/%s-%s%s", s3Endpoint, app, app, version, ext)
}
var electronVersionID string
t.Run("electron_builder_macos_with_both_blockmaps", func(t *testing.T) {
electronVersionID = uploadLatestFlowVersion(t, router, app, "1.0.1", "macos", "electron-builder", "",
"latest-mac.yml", app+"-1.0.1-mac.zip", app+"-1.0.1-mac.zip.blockmap", app+"-1.0.1.dmg", app+"-1.0.1.dmg.blockmap")
require.ElementsMatch(t, []string{".yml", ".zip", ".blockmap", ".dmg", ".blockmap"}, storedVersionPackages(t, app, "1.0.1"))
w := serveVersionCheck(t, fmt.Sprintf("/checkVersion?app_name=%s&version=0.0.0.1&channel=nightly&platform=macos&arch=universalArch&owner=admin&updater=electron-builder", app), nil)
require.Equal(t, http.StatusFound, w.Code, w.Body.String())
require.True(t, strings.HasSuffix(w.Header().Get("Location"), "/latest-mac.yml"), "redirected to %q", w.Header().Get("Location"))
})
t.Run("colliding_files_in_one_request_write_nothing", func(t *testing.T) {
w := doUpdaterRequest(t, router, "/upload",
fmt.Sprintf(`{"app_name":"%s","version":"1.0.2","channel":"nightly","publish":true,"platform":"macos","arch":"universalArch","updater":"manual"}`, app),
[]updaterFeedFile{{name: "first.zip", content: []byte("first")}, {name: "second.dmg", content: []byte("dmg")}, {name: "third.zip", content: []byte("third")}})
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var resp map[string]string
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Contains(t, resp["error"], "first.zip")
require.Contains(t, resp["error"], "third.zip")
require.Nil(t, storedVersionPackages(t, app, "1.0.2"), "rejected request must not record a version")
require.NotEqual(t, http.StatusOK, storageObjectStatus(t, defaultLayoutURL("1.0.2", ".zip")), "rejected request must not write to storage")
require.NotEqual(t, http.StatusOK, storageObjectStatus(t, defaultLayoutURL("1.0.2", ".dmg")), "rejected request must not write to storage")
})
t.Run("stored_conflict_keeps_existing_file_and_version", func(t *testing.T) {
uploadLatestFlowVersion(t, router, app, "1.0.3", "macos", "manual", "", app+"-1.0.3.dmg")
original := checkFileContent(t, defaultLayoutURL("1.0.3", ".dmg"))
require.Equal(t, app+"-1.0.3.dmg", original)
w := doUpdaterRequest(t, router, "/upload",
fmt.Sprintf(`{"app_name":"%s","version":"1.0.3","channel":"nightly","publish":true,"platform":"macos","arch":"universalArch","updater":"manual"}`, app),
[]updaterFeedFile{{name: "retry.zip", content: []byte("new zip")}, {name: "retry.dmg", content: []byte("replaced")}})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.JSONEq(t, `{"error":"app with this name, version, platform, architecture and extension already exists"}`, w.Body.String())
require.Equal(t, original, checkFileContent(t, defaultLayoutURL("1.0.3", ".dmg")), "stored file must not be overwritten")
require.Equal(t, []string{".dmg"}, storedVersionPackages(t, app, "1.0.3"), "no artifact of the rejected request may be recorded")
require.NotEqual(t, http.StatusOK, storageObjectStatus(t, defaultLayoutURL("1.0.3", ".zip")))
})
// /apps/update used to overwrite the stored object and then skip the artifact as a
// duplicate, leaving hashes (and TUF targets) describing the old bytes.
t.Run("apps_update_rejects_stored_file", func(t *testing.T) {
require.NotEmpty(t, electronVersionID)
dmgURL := fmt.Sprintf("%s/electron-builder/%s-admin/1.0.1/nightly/macos/universalArch/%s-1.0.1.dmg", s3Endpoint, app, app)
original := checkFileContent(t, dmgURL)
update := func(extra string, files ...updaterFeedFile) *httptest.ResponseRecorder {
return doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"%s","version":"1.0.1","publish":true,"platform":"macos","arch":"universalArch","updater":"electron-builder"%s}`, electronVersionID, app, extra),
files)
}
w := update(`,"channel":"nightly"`, updaterFeedFile{name: "latest-mac.yml", content: []byte("version: 1.0.1")}, updaterFeedFile{name: app + "-1.0.1.dmg", content: []byte("replaced")})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.JSONEq(t, `{"error":"app with this name, version, platform, architecture and extension already exists"}`, w.Body.String())
require.Equal(t, original, checkFileContent(t, dmgURL), "stored file must not be overwritten")
w = update(`,"channel":"stable"`, updaterFeedFile{name: "latest-mac.yml", content: []byte("version: 1.0.1")}, updaterFeedFile{name: app + "-1.0.1.pkg", content: []byte("pkg")})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), "cannot be changed")
// An omitted channel keeps the version's own, and the feed may be re-sent with a new file.
w = update("", updaterFeedFile{name: "latest-mac.yml", content: []byte("version: 1.0.1")}, updaterFeedFile{name: app + "-1.0.1.pkg", content: []byte("pkg")})
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Contains(t, storedVersionPackages(t, app, "1.0.1"), ".pkg")
require.Equal(t, original, checkFileContent(t, dmgURL))
})
// .nupkg, .delta and .blockmap may repeat within a platform/arch, but the default
// layout stores every one of them as {app}-{version}{ext}: a second file used to
// overwrite the first and add a second artifact with the same link.
t.Run("repeatable_extension_same_key_rejected", func(t *testing.T) {
upload := func(name, content string) *httptest.ResponseRecorder {
return doUpdaterRequest(t, router, "/upload",
fmt.Sprintf(`{"app_name":"%s","version":"1.0.4","channel":"nightly","publish":true,"platform":"macos","arch":"universalArch","updater":"manual"}`, app),
[]updaterFeedFile{{name: name, content: []byte(content)}})
}
w := upload("first.blockmap", "first")
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
w = upload("second.blockmap", "second")
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
require.Equal(t, "first", checkFileContent(t, defaultLayoutURL("1.0.4", ".blockmap")), "stored file must not be overwritten")
require.Equal(t, []string{".blockmap"}, storedVersionPackages(t, app, "1.0.4"))
})
t.Run("apps_update_rejects_same_repeatable_file", func(t *testing.T) {
name := app + "-1.0.1-mac.zip.blockmap"
w := doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"%s","version":"1.0.1","channel":"nightly","publish":true,"platform":"macos","arch":"universalArch","updater":"electron-builder"}`, electronVersionID, app),
[]updaterFeedFile{{name: "latest-mac.yml", content: []byte("version: 1.0.1")}, {name: name, content: []byte("replaced")}})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
blockmapURL := fmt.Sprintf("%s/electron-builder/%s-admin/1.0.1/nightly/macos/universalArch/%s", s3Endpoint, app, name)
require.Equal(t, name, checkFileContent(t, blockmapURL), "stored file must not be overwritten")
})
// The channel is part of the storage key; an update that omits it must still place
// files in the version's channel folder, so a re-sent file meets its stored key.
t.Run("apps_update_without_channel_uses_version_channel", func(t *testing.T) {
name := app + "-1.0.1-mac.zip.blockmap"
w := doUpdaterRequest(t, router, "/apps/update",
fmt.Sprintf(`{"id":"%s","app_name":"%s","version":"1.0.1","publish":true,"platform":"macos","arch":"universalArch","updater":"electron-builder"}`, electronVersionID, app),
[]updaterFeedFile{{name: "latest-mac.yml", content: []byte("version: 1.0.1")}, {name: name, content: []byte("replaced")}})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
var doc model.SpecificApp
id, err := primitive.ObjectIDFromHex(electronVersionID)
require.NoError(t, err)
require.NoError(t, mongoDatabase.Collection("apps").FindOne(context.Background(), bson.M{"_id": id}).Decode(&doc))
for _, artifact := range doc.Artifacts {
require.Contains(t, artifact.Link, "/1.0.1/nightly/macos/", "artifact stored outside the version's channel folder")
}
})
// A concurrent request passes the pre-write check before either is recorded; the
// append itself must still refuse a second artifact with the same link.
t.Run("repository_refuses_same_link_twice", func(t *testing.T) {
ctxQuery := map[string]interface{}{
"app_name": app, "version": "1.0.4", "channel": "nightly", "platform": "macos", "arch": "universalArch",
"signature": "", "changelog": "", "publish": "true",
}
link := defaultLayoutURL("1.0.4", ".blockmap")
ctx := utils.WithPrincipal(context.Background(), utils.Principal{Type: utils.PrincipalAdmin, Username: "admin"})
_, err := appDB.Upload(ctxQuery, link, ".blockmap", "admin", ctx, redisClient, viper.GetViper(), false)
require.Error(t, err)
require.Contains(t, err.Error(), "already exists")
_, err = appDB.Upload(ctxQuery, defaultLayoutURL("1.0.4", "-other.blockmap"), ".blockmap", "admin", ctx, redisClient, viper.GetViper(), false)
require.NoError(t, err, "a different link of a repeatable extension may still be added")
require.Equal(t, []string{".blockmap", ".blockmap"}, storedVersionPackages(t, app, "1.0.4"))
})
}
// Channel, platform and arch names are unique only per owner. The "required" checks
// stay instance-wide by design, but a name lookup must only see the caller's own
// entities: another admin's channel used to pass validation (and the upload then wrote
// to storage before failing), and /checkVersion resolved the updaters of whichever
// admin's platform with that name came first.
func TestEntityLookupsAreOwnerScoped(t *testing.T) {
const app = "ownerscopeapp"
const platform = "ownerscopedplatform"
ctx := context.Background()
cleanupMeta := func() {
_, err := mongoDatabase.Collection("apps_meta").DeleteMany(ctx, bson.M{"$or": bson.A{
bson.M{"channel_name": "secondonlychannel"},
bson.M{"platform_name": bson.M{"$in": bson.A{"secondonlyplatform", platform}}},
bson.M{"arch_id": "secondonlyarch"},
}})
require.NoError(t, err)
}
cleanupUpdaterApp(t, app)
cleanupMeta()
defer cleanupMeta()
defer cleanupUpdaterApp(t, app)
h := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
metaRouter := gin.Default()
metaRouter.Use(utils.AuthMiddleware(mongoDatabase))
metaRouter.POST("/channel/create", func(c *gin.Context) { h.CreateChannel(c) })
metaRouter.POST("/platform/create", func(c *gin.Context) { h.CreatePlatform(c) })
metaRouter.POST("/arch/create", func(c *gin.Context) { h.CreateArch(c) })
createMeta := func(token, path, body string) {
t.Helper()
req, err := http.NewRequest("POST", path, strings.NewReader(body))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
metaRouter.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "%s %s: %s", path, body, w.Body.String())
}
// The second admin's platform is created first, so an owner-less lookup finds it first.
createMeta(authTokenSecondUser, "/channel/create", `{"channel":"secondonlychannel"}`)
createMeta(authTokenSecondUser, "/platform/create", `{"platform":"secondonlyplatform"}`)
createMeta(authTokenSecondUser, "/arch/create", `{"arch":"secondonlyarch"}`)
createMeta(authTokenSecondUser, "/platform/create", `{"platform":"`+platform+`","updaters":[{"type":"manual","default":true}]}`)
createMeta(authToken, "/platform/create", `{"platform":"`+platform+`","updaters":[{"type":"manual","default":false},{"type":"tauri","default":true}]}`)
router := updaterFlowRouter()
createUpdaterApp(t, router, app)
t.Run("another_admins_entities_are_unknown", func(t *testing.T) {
cases := []struct{ channel, platform, arch, wantError string }{
{"secondonlychannel", "macos", "universalArch", "Channel does not exist"},
{"nightly", "secondonlyplatform", "universalArch", "Platform does not exist"},
{"nightly", "macos", "secondonlyarch", "Arch does not exist"},
}
for _, tc := range cases {
w := doUpdaterRequest(t, router, "/upload",
fmt.Sprintf(`{"app_name":"%s","version":"1.0.0","channel":"%s","publish":true,"platform":"%s","arch":"%s","updater":"manual"}`, app, tc.channel, tc.platform, tc.arch),
[]updaterFeedFile{{name: "build.dmg", content: []byte("dmg")}})
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
require.Contains(t, w.Body.String(), tc.wantError)
url := fmt.Sprintf("%s/%s-admin/%s/%s/%s/%s-1.0.0.dmg", s3Endpoint, app, tc.channel, tc.platform, tc.arch, app)
require.NotEqual(t, http.StatusOK, storageObjectStatus(t, url), "rejected upload must not write to storage")
}
require.Nil(t, storedVersionPackages(t, app, "1.0.0"))
})
t.Run("checkversion_uses_own_platform_updaters", func(t *testing.T) {
uploadLatestFlowVersion(t, router, app, "1.0.1", platform, "tauri", `,"signature":"c2ln"`, app+".app.tar.gz")
w := serveVersionCheck(t, fmt.Sprintf("/checkVersion?app_name=%s&version=0.0.1&channel=nightly&platform=%s&arch=universalArch&owner=admin", app, platform), nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Equal(t, "1.0.1", resp["version"], "default updater must be the caller's tauri, got %s", w.Body.String())
require.NotContains(t, resp, "update_available")
})
}
// ============================================================================
// Presigned upload flow (self-contained block).
// init -> direct PUT to storage -> complete, against the real configured storage.
// Pins the contract TUF publish relies on: client-declared hashes are stored
// with hashes_verified=false, feed files hashed by faynoSync with true.
// Reuses channels (nightly), platforms (windows/universalPlatform) and arch
// (universalArch) created earlier in the ordered suite.
// ============================================================================
type presignedInitResponse struct {
UploadID string `json:"upload_id"`
Files []struct {
Name string `json:"name"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
} `json:"files"`
}
func presignedManifest(files []updaterFeedFile) string {
entries := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
md := md5.Sum(f.content)
s256 := sha256.Sum256(f.content)
s512 := sha512.Sum512(f.content)
entries = append(entries, map[string]interface{}{
"name": f.name,
"md5": hex.EncodeToString(md[:]),
"sha256": hex.EncodeToString(s256[:]),
"sha512": hex.EncodeToString(s512[:]),
"length": len(f.content),
})
}
raw, _ := json.Marshal(entries)
return string(raw)
}
func doPresignedInit(t *testing.T, router *gin.Engine, token, payload, manifest string, inline []updaterFeedFile) *httptest.ResponseRecorder {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
for _, f := range inline {
part, err := writer.CreateFormFile("file", f.name)
require.NoError(t, err)
_, err = part.Write(f.content)
require.NoError(t, err)
}
require.NoError(t, writer.WriteField("data", payload))
if manifest != "" {
require.NoError(t, writer.WriteField("files", manifest))
}
require.NoError(t, writer.Close())
req, err := http.NewRequest("POST", "/upload/init", body)
require.NoError(t, err)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func doPresignedComplete(t *testing.T, router *gin.Engine, token, uploadID string) *httptest.ResponseRecorder {
t.Helper()
req, err := http.NewRequest("POST", "/upload/complete", strings.NewReader(fmt.Sprintf(`{"upload_id":"%s"}`, uploadID)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// presignedMD5OnlyManifest declares only what init requires: sha256/sha512 are needed
// solely for TUF, and the length is taken from storage.
func presignedMD5OnlyManifest(files []updaterFeedFile) string {
entries := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
md := md5.Sum(f.content)
entries = append(entries, map[string]interface{}{"name": f.name, "md5": hex.EncodeToString(md[:])})
}
raw, _ := json.Marshal(entries)
return string(raw)
}
func presignedInitOK(t *testing.T, router *gin.Engine, payload string, presigned, inline []updaterFeedFile) presignedInitResponse {
t.Helper()
return presignedInitWithManifest(t, router, payload, presignedManifest(presigned), len(presigned), inline)
}
func presignedInitWithManifest(t *testing.T, router *gin.Engine, payload, manifest string, files int, inline []updaterFeedFile) presignedInitResponse {
t.Helper()
w := doPresignedInit(t, router, authToken, payload, manifest, inline)
require.Equal(t, http.StatusOK, w.Code, "init: %s", w.Body.String())
var resp presignedInitResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Len(t, resp.UploadID, 32)
require.Len(t, resp.Files, files)
return resp
}
// putPresigned sends the bytes straight to storage, the way the CLI or curl would.
func putPresigned(t *testing.T, upload presignedInitResponse, name string, content []byte) (int, string) {
t.Helper()
for _, f := range upload.Files {
if f.Name != name {
continue
}
require.Equal(t, http.MethodPut, f.Method)
req, err := http.NewRequest(http.MethodPut, f.URL, bytes.NewReader(content))
require.NoError(t, err)
for k, v := range f.Headers {
req.Header.Set(k, v)
}
req.ContentLength = int64(len(content))
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(respBody)
}
t.Fatalf("file %s is not in the init response", name)
return 0, ""
}
func pendingUploadKeys(t *testing.T, uploadID string) []string {
t.Helper()
var doc struct {
Files []struct {
PendingKey string `bson:"pending_key"`
} `bson:"files"`
}
require.NoError(t, mongoDatabase.Collection("pending_uploads").FindOne(context.Background(), bson.M{"_id": uploadID}).Decode(&doc))
keys := make([]string, 0, len(doc.Files))
for _, f := range doc.Files {
keys = append(keys, f.PendingKey)
}
return keys
}
func uploadedVersion(t *testing.T, w *httptest.ResponseRecorder) model.SpecificApp {
t.Helper()
require.Equal(t, http.StatusOK, w.Code, "complete: %s", w.Body.String())
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
id, _ := resp["uploadResult.Uploaded"].(string)
objectID, err := primitive.ObjectIDFromHex(id)
require.NoError(t, err, "no upload id in %s", w.Body.String())
var doc model.SpecificApp
require.NoError(t, mongoDatabase.Collection("apps").FindOne(context.Background(), bson.M{"_id": objectID}).Decode(&doc))
return doc
}
func artifactByLinkSuffix(t *testing.T, doc model.SpecificApp, suffix string) model.Artifact {
t.Helper()
for _, a := range doc.Artifacts {
if strings.HasSuffix(a.Link, suffix) {
return a
}
}
t.Fatalf("no artifact with link suffix %s in %+v", suffix, doc.Artifacts)
return model.Artifact{}
}
func TestPresignedUploadFlow(t *testing.T) {
storageClient, err := utils.NewStorageFactory(viper.GetViper()).CreateStorageClient()
require.NoError(t, err)
uploader, ok := storageClient.(storage.PresignedUploader)
if !ok {
t.Skipf("storage driver %q does not support presigned uploads", viper.GetString("STORAGE_DRIVER"))
}
cleanupUpdaterApp(t, "presignedapp")
cleanupUpdaterApp(t, "presignedprivate")
defer cleanupUpdaterApp(t, "presignedapp")
defer cleanupUpdaterApp(t, "presignedprivate")
router := updaterFlowRouter()
createUpdaterApp(t, router, "presignedapp")
nupkgName := "presignedapp-2.0.0-full.nupkg"
nupkg := updaterFeedFile{name: nupkgName, content: []byte(strings.Repeat("presigned nupkg payload ", 64))}
s256 := sha256.Sum256(nupkg.content)
s1 := fmt.Sprintf("%x", s256[:20])
feed := updaterFeedFile{
name: "releases.nightly.json",
content: []byte(fmt.Sprintf(`{"Assets":[{"Type":"Full","FileName":"%s","SHA1":"%s","SHA256":"%x","Size":%d}]}`, nupkgName, s1, s256, len(nupkg.content))),
}
payload := `{"app_name":"presignedapp","version":"2.0.0","channel":"nightly","publish":true,"critical":false,"platform":"windows","arch":"universalArch","updater":"velopack","changelog":"presigned release"}`
t.Run("init_rejects_before_any_transfer", func(t *testing.T) {
cases := []struct {
name string
manifest string
inline []updaterFeedFile
want int
}{
{"missing_manifest", "", []updaterFeedFile{feed}, http.StatusBadRequest},
{"feed_must_be_inline", presignedManifest([]updaterFeedFile{nupkg, feed}), nil, http.StatusBadRequest},
{"package_must_not_be_inline", presignedManifest([]updaterFeedFile{nupkg}), []updaterFeedFile{feed, {name: "extra.exe", content: []byte("x")}}, http.StatusBadRequest},
{"updater_rules_apply", presignedManifest([]updaterFeedFile{{name: "presignedapp-2.0.0.exe", content: []byte("x")}}), []updaterFeedFile{feed}, http.StatusBadRequest},
{"path_in_name", presignedManifest([]updaterFeedFile{{name: "../" + nupkgName, content: nupkg.content}}), []updaterFeedFile{feed}, http.StatusBadRequest},
}
for _, tc := range cases {
w := doPresignedInit(t, router, authToken, payload, tc.manifest, tc.inline)
assert.Equal(t, tc.want, w.Code, "%s: %s", tc.name, w.Body.String())
}
})
upload := presignedInitOK(t, router, payload, []updaterFeedFile{nupkg}, []updaterFeedFile{feed})
pendingKeys := pendingUploadKeys(t, upload.UploadID)
require.Len(t, pendingKeys, 2)
t.Run("complete_before_put_is_rejected", func(t *testing.T) {
w := doPresignedComplete(t, router, authToken, upload.UploadID)
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "not uploaded")
})
t.Run("storage_rejects_tampered_bytes", func(t *testing.T) {
tampered := append([]byte{}, nupkg.content...)
tampered[0] ^= 0xff
status, body := putPresigned(t, upload, nupkgName, tampered)
require.GreaterOrEqual(t, status, 400, "tampered payload accepted: %s", body)
})
t.Run("other_user_cannot_complete", func(t *testing.T) {
w := doPresignedComplete(t, router, authTokenSecondUser, upload.UploadID)
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
})
status, body := putPresigned(t, upload, nupkgName, nupkg.content)
require.Less(t, status, 300, "PUT: %s", body)
doc := uploadedVersion(t, doPresignedComplete(t, router, authToken, upload.UploadID))
t.Run("artifacts_carry_hash_provenance", func(t *testing.T) {
pkg := artifactByLinkSuffix(t, doc, "/"+nupkgName)
s512 := sha512.Sum512(nupkg.content)
assert.False(t, pkg.HashesVerified, "client-declared hashes must wait for TUF publish verification")
assert.Equal(t, hex.EncodeToString(s256[:]), pkg.Hashes["sha256"])
assert.Equal(t, hex.EncodeToString(s512[:]), pkg.Hashes["sha512"])
assert.Equal(t, int64(len(nupkg.content)), pkg.Length)
assert.False(t, pkg.IsFeed)
feedArtifact := artifactByLinkSuffix(t, doc, "/releases.nightly.json")
assert.True(t, feedArtifact.IsFeed)
assert.True(t, feedArtifact.HashesVerified, "inline feed is hashed by faynoSync itself")
})
t.Run("objects_moved_out_of_staging", func(t *testing.T) {
ctx := context.Background()
bucket := viper.GetString("S3_BUCKET_NAME")
key := fmt.Sprintf("velopack/admin/presignedapp/windows/universalArch/%s", nupkgName)
stat, err := uploader.StatObject(ctx, bucket, key)
require.NoError(t, err)
md := md5.Sum(nupkg.content)
assert.Equal(t, int64(len(nupkg.content)), stat.Size)
assert.Equal(t, hex.EncodeToString(md[:]), stat.MD5)
for _, pendingKey := range pendingKeys {
_, err := uploader.StatObject(ctx, bucket, pendingKey)
assert.ErrorIs(t, err, storage.ErrObjectNotFound, "staged object %s must be deleted", pendingKey)
}
count, err := mongoDatabase.Collection("pending_uploads").CountDocuments(ctx, bson.M{"_id": upload.UploadID})
require.NoError(t, err)
assert.Zero(t, count)
})
t.Run("feed_materialized", func(t *testing.T) {
assets := parseVelopackFeed(t, checkFileContent(t, fmt.Sprintf("%s/velopack/admin/presignedapp/windows/universalArch/releases.nightly.json", s3Endpoint)))
require.Contains(t, assets, "2.0.0", "published presigned version must be in the feed")
})
t.Run("complete_is_single_use", func(t *testing.T) {
w := doPresignedComplete(t, router, authToken, upload.UploadID)
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
})
t.Run("init_detects_existing_artifact", func(t *testing.T) {
w := doPresignedInit(t, router, authToken, payload, presignedManifest([]updaterFeedFile{nupkg}), []updaterFeedFile{feed})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
})
velopackRelease := func(version, suffix string) (updaterFeedFile, updaterFeedFile) {
pkg := updaterFeedFile{
name: fmt.Sprintf("presignedapp-%s-full.nupkg", version),
content: []byte(strings.Repeat("presigned "+version+" "+suffix+" payload ", 32)),
}
sum := sha256.Sum256(pkg.content)
releases := updaterFeedFile{
name: "releases.nightly.json",
content: []byte(fmt.Sprintf(`{"Assets":[{"Type":"Full","FileName":"%s","SHA1":"%x","SHA256":"%x","Size":%d}]}`, pkg.name, sum[:20], sum, len(pkg.content))),
}
return pkg, releases
}
velopackPayload := func(version, channel, platform string) string {
return fmt.Sprintf(`{"app_name":"presignedapp","version":"%s","channel":"%s","publish":true,"critical":false,"platform":"%s","arch":"universalArch","updater":"velopack"}`, version, channel, platform)
}
platformID := func(name string) primitive.ObjectID {
var meta struct {
ID primitive.ObjectID `bson:"_id"`
}
require.NoError(t, mongoDatabase.Collection("apps_meta").FindOne(context.Background(), bson.M{"platform_name": name, "owner": "admin"}).Decode(&meta))
return meta.ID
}
t.Run("another_platform_appends_to_the_same_version", func(t *testing.T) {
pkg, releases := velopackRelease("2.0.0", "macos")
macUpload := presignedInitOK(t, router, velopackPayload("2.0.0", "nightly", "macos"), []updaterFeedFile{pkg}, []updaterFeedFile{releases})
status, body := putPresigned(t, macUpload, pkg.name, pkg.content)
require.Less(t, status, 300, "PUT: %s", body)
appended := uploadedVersion(t, doPresignedComplete(t, router, authToken, macUpload.UploadID))
assert.Equal(t, doc.ID, appended.ID, "a second platform must land in the existing version")
assert.Equal(t, platformID("macos"), artifactByLinkSuffix(t, appended, "/macos/universalArch/"+pkg.name).Platform)
assert.Equal(t, platformID("windows"), artifactByLinkSuffix(t, appended, "/windows/universalArch/"+nupkgName).Platform)
})
t.Run("channel_of_a_version_is_immutable", func(t *testing.T) {
pkg, releases := velopackRelease("2.0.0", "stable")
releases.name = "releases.stable.json"
w := doPresignedInit(t, router, authToken, velopackPayload("2.0.0", "stable", "macosSquirrel"), presignedManifest([]updaterFeedFile{pkg}), []updaterFeedFile{releases})
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "cannot be changed")
w = doUpdaterRequest(t, router, "/upload", velopackPayload("2.0.0", "stable", "macosSquirrel"), []updaterFeedFile{releases, pkg})
require.NotEqual(t, http.StatusOK, w.Code, "multipart upload must not append to a version of another channel: %s", w.Body.String())
assert.Contains(t, w.Body.String(), "cannot be changed")
})
// Two platforms of a new version completed at the same time, the way parallel CI jobs
// do: both must end up in one version document, each artifact with its own platform.
t.Run("parallel_completes_build_one_version", func(t *testing.T) {
platforms := []string{"windows", "macos"}
uploads := make([]presignedInitResponse, len(platforms))
pkgs := make([]updaterFeedFile, len(platforms))
for i, platform := range platforms {
pkg, releases := velopackRelease("2.1.0", platform)
pkgs[i] = pkg
uploads[i] = presignedInitOK(t, router, velopackPayload("2.1.0", "nightly", platform), []updaterFeedFile{pkg}, []updaterFeedFile{releases})
status, body := putPresigned(t, uploads[i], pkg.name, pkg.content)
require.Less(t, status, 300, "PUT %s: %s", platform, body)
}
codes := make([]int, len(platforms))
bodies := make([]string, len(platforms))
done := make(chan struct{})
for i := range platforms {
go func(i int) {
defer func() { done <- struct{}{} }()
req, _ := http.NewRequest("POST", "/upload/complete", strings.NewReader(fmt.Sprintf(`{"upload_id":"%s"}`, uploads[i].UploadID)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
codes[i], bodies[i] = w.Code, w.Body.String()
}(i)
}
for range platforms {
<-done
}
for i, platform := range platforms {
require.Equal(t, http.StatusOK, codes[i], "complete %s: %s", platform, bodies[i])
}
count, err := mongoDatabase.Collection("apps").CountDocuments(context.Background(), bson.M{"app_id": doc.AppID, "version": "2.1.0"})
require.NoError(t, err)
require.EqualValues(t, 1, count, "concurrent uploads must not create the version twice")
var version model.SpecificApp
require.NoError(t, mongoDatabase.Collection("apps").FindOne(context.Background(), bson.M{"app_id": doc.AppID, "version": "2.1.0"}).Decode(&version))
require.Len(t, version.Artifacts, 2*len(platforms), "every platform contributes a package and a feed; none may be lost")
for i, platform := range platforms {
pkg := artifactByLinkSuffix(t, version, "/"+platform+"/universalArch/"+pkgs[i].name)
assert.Equal(t, platformID(platform), pkg.Platform, "artifact stored under %s must carry its own platform", platform)
}
})
// Final object keys are deterministic, so an artifact recorded after init must stop
// complete before its object is overwritten with different bytes.
t.Run("complete_rechecks_conflict", func(t *testing.T) {
first, firstReleases := velopackRelease("2.2.0", "first")
second, secondReleases := velopackRelease("2.2.0", "second")
require.Equal(t, first.name, second.name)
firstUpload := presignedInitOK(t, router, velopackPayload("2.2.0", "nightly", "windows"), []updaterFeedFile{first}, []updaterFeedFile{firstReleases})
secondUpload := presignedInitOK(t, router, velopackPayload("2.2.0", "nightly", "windows"), []updaterFeedFile{second}, []updaterFeedFile{secondReleases})
status, body := putPresigned(t, firstUpload, first.name, first.content)
require.Less(t, status, 300, "PUT first: %s", body)
status, body = putPresigned(t, secondUpload, second.name, second.content)
require.Less(t, status, 300, "PUT second: %s", body)
uploadedVersion(t, doPresignedComplete(t, router, authToken, firstUpload.UploadID))
w := doPresignedComplete(t, router, authToken, secondUpload.UploadID)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
stat, err := uploader.StatObject(context.Background(), viper.GetString("S3_BUCKET_NAME"), "velopack/admin/presignedapp/windows/universalArch/"+first.name)
require.NoError(t, err)
md := md5.Sum(first.content)
assert.Equal(t, hex.EncodeToString(md[:]), stat.MD5, "the recorded artifact must keep its bytes")
})
// A complete that fails before any artifact is recorded must stay retryable.
t.Run("complete_releases_when_nothing_recorded", func(t *testing.T) {
pkg, releases := velopackRelease("2.3.0", "retry")
retryUpload := presignedInitOK(t, router, velopackPayload("2.3.0", "nightly", "windows"), []updaterFeedFile{pkg}, []updaterFeedFile{releases})
status, body := putPresigned(t, retryUpload, pkg.name, pkg.content)
require.Less(t, status, 300, "PUT: %s", body)
ctx := context.Background()
metaCollection := mongoDatabase.Collection("apps_meta")
var arch bson.M
require.NoError(t, metaCollection.FindOne(ctx, bson.M{"arch_id": "universalArch", "owner": "admin"}).Decode(&arch))
_, err := metaCollection.DeleteOne(ctx, bson.M{"_id": arch["_id"]})
require.NoError(t, err)
restored := false
restore := func() {
if !restored {
_, err := metaCollection.InsertOne(ctx, arch)
require.NoError(t, err)
restored = true
}
}
defer restore()
for attempt := 0; attempt < 2; attempt++ {
w := doPresignedComplete(t, router, authToken, retryUpload.UploadID)
require.Equal(t, http.StatusInternalServerError, w.Code, "attempt %d: %s", attempt, w.Body.String())
assert.Contains(t, w.Body.String(), "arch not found")
}
var pending struct {
State string `bson:"state"`
}
require.NoError(t, mongoDatabase.Collection("pending_uploads").FindOne(ctx, bson.M{"_id": retryUpload.UploadID}).Decode(&pending))
assert.Equal(t, "pending", pending.State)
restore()
uploadedVersion(t, doPresignedComplete(t, router, authToken, retryUpload.UploadID))
})
t.Run("private_app_md5_only_manifest", func(t *testing.T) {
w := doUpdaterRequest(t, router, "/app/create", `{"app": "presignedprivate", "private": "true"}`, nil)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
file := updaterFeedFile{name: "presignedprivate.zip", content: []byte("private presigned payload")}
privateInit := presignedInitWithManifest(t, router,
`{"app_name":"presignedprivate","version":"0.0.1","channel":"nightly","publish":true,"critical":false,"platform":"universalPlatform","arch":"universalArch"}`,
presignedMD5OnlyManifest([]updaterFeedFile{file}), 1, nil)
_, lengthSigned := privateInit.Files[0].Headers["Content-Length"]
assert.False(t, lengthSigned, "an undeclared length must not be signed")
status, body := putPresigned(t, privateInit, file.name, file.content)
require.Less(t, status, 300, "PUT: %s", body)
privateDoc := uploadedVersion(t, doPresignedComplete(t, router, authToken, privateInit.UploadID))
require.Len(t, privateDoc.Artifacts, 1)
link := privateDoc.Artifacts[0].Link
require.True(t, strings.HasPrefix(link, apiUrl+"/download?key="), "private artifact must be served through the API: %s", link)
assert.False(t, privateDoc.Artifacts[0].HashesVerified)
assert.Empty(t, privateDoc.Artifacts[0].Hashes, "undeclared hashes stay empty; such an artifact is not signable by TUF")
assert.Equal(t, int64(len(file.content)), privateDoc.Artifacts[0].Length, "length must come from storage when not declared")
key, err := url.QueryUnescape(strings.TrimPrefix(link, apiUrl+"/download?key="))
require.NoError(t, err)
stat, err := uploader.StatObject(context.Background(), viper.GetString("S3_BUCKET_NAME_PRIVATE"), key)
require.NoError(t, err, "object must be in the private bucket")
assert.Equal(t, int64(len(file.content)), stat.Size)
})
}
// TestPresignedUploadTeamUserScope checks that a team user's allowed lists are enforced by
// multipart and presigned uploads before anything reaches storage. Self-contained: it
// creates its own apps and team user and removes them afterwards.
func TestPresignedUploadTeamUserScope(t *testing.T) {
storageClient, err := utils.NewStorageFactory(viper.GetViper()).CreateStorageClient()
require.NoError(t, err)
uploader, ok := storageClient.(storage.PresignedUploader)
if !ok {
t.Skipf("storage driver %q does not support presigned uploads", viper.GetString("STORAGE_DRIVER"))
}
ctx := context.Background()
const (
teamUser = "teamscopeuser"
allowedApp = "teamscopeapp"
forbiddenApp = "teamscopeother"
apiTokenName = "teamscope-token"
)
teamUsers := mongoDatabase.Collection("team_users")
pendingUploads := mongoDatabase.Collection("pending_uploads")
bucket := viper.GetString("S3_BUCKET_NAME")
var writtenKeys []string
cleanup := func() {
_, _ = teamUsers.DeleteOne(ctx, bson.M{"username": teamUser})
_, _ = mongoDatabase.Collection("api_tokens").DeleteMany(ctx, bson.M{"name": apiTokenName, "owner": "admin"})
_, _ = pendingUploads.DeleteMany(ctx, bson.M{"username": teamUser})
if len(writtenKeys) > 0 {
_ = storageClient.DeleteObjects(ctx, bucket, writtenKeys)
}
cleanupUpdaterApp(t, allowedApp)
cleanupUpdaterApp(t, forbiddenApp)
}
cleanup()
defer cleanup()
adminRouter := updaterFlowRouter()
createUpdaterApp(t, adminRouter, allowedApp)
createUpdaterApp(t, adminRouter, forbiddenApp)
metaID := func(field, value string) string {
var meta struct {
ID primitive.ObjectID `bson:"_id"`
}
require.NoError(t, mongoDatabase.Collection("apps_meta").FindOne(ctx, bson.M{field: value, "owner": "admin"}).Decode(&meta))
return meta.ID.Hex()
}
appID := metaID("app_name", allowedApp)
channelID := metaID("channel_name", "nightly")
platformID := metaID("platform_name", "universalPlatform")
archID := metaID("arch_id", "universalArch")
h := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router := gin.Default()
router.POST("/login", func(c *gin.Context) { h.Login(c) })
router.POST("/user/create", utils.AuthMiddleware(mongoDatabase), utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) { h.CreateTeamUser(c) })
router.POST("/token/create", utils.AuthMiddleware(mongoDatabase), utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) { h.CreateToken(c) })
uploadPermission := utils.CheckPermission(utils.PermissionUpload, utils.ResourceApps, mongoDatabase)
router.POST("/upload", utils.AuthMiddleware(mongoDatabase), uploadPermission, func(c *gin.Context) { h.UploadApp(c) })
router.POST("/upload/init", utils.AuthMiddleware(mongoDatabase), uploadPermission, func(c *gin.Context) { h.InitPresignedUpload(c) })
router.POST("/upload/complete", utils.AuthMiddleware(mongoDatabase), uploadPermission, func(c *gin.Context) { h.CompletePresignedUpload(c) })
createBody, err := json.Marshal(map[string]interface{}{
"username": teamUser,
"password": "password123",
"permissions": map[string]interface{}{
"apps": map[string]interface{}{"upload": true, "allowed": []string{appID}},
"channels": map[string]interface{}{"allowed": []string{channelID}},
"platforms": map[string]interface{}{"allowed": []string{platformID}},
"archs": map[string]interface{}{"allowed": []string{archID}},
},
})
require.NoError(t, err)
req, err := http.NewRequest("POST", "/user/create", bytes.NewReader(createBody))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "create team user: %s", w.Body.String())
var stored model.TeamUser
require.NoError(t, teamUsers.FindOne(ctx, bson.M{"username": teamUser}).Decode(&stored))
require.Equal(t, []string{appID}, stored.Permissions.Apps.Allowed)
require.Equal(t, []string{channelID}, stored.Permissions.Channels.Allowed)
req, err = http.NewRequest("POST", "/login", strings.NewReader(fmt.Sprintf(`{"username":"%s","password":"password123"}`, teamUser)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "login: %s", w.Body.String())
var login map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &login))
token, _ := login["token"].(string)
require.NotEmpty(t, token)
revoke := func(t *testing.T, field, id string) func() {
t.Helper()
_, err := teamUsers.UpdateOne(ctx, bson.M{"username": teamUser}, bson.M{"$pull": bson.M{field: id}})
require.NoError(t, err)
return func() {
_, err := teamUsers.UpdateOne(ctx, bson.M{"username": teamUser}, bson.M{"$addToSet": bson.M{field: id}})
require.NoError(t, err)
}
}
payload := func(app, version, channel string) string {
return fmt.Sprintf(`{"app_name":"%s","version":"%s","channel":"%s","publish":true,"critical":false,"platform":"universalPlatform","arch":"universalArch"}`, app, version, channel)
}
artifact := func(app, version string) updaterFeedFile {
return updaterFeedFile{name: app + ".zip", content: []byte("team scope payload " + app + " " + version)}
}
// objectKey is where an upload with these parameters lands, so the test can prove nothing was written there.
objectKey := func(app, version, channel, name string) string {
key := utils.BuildObjectPlacement(map[string]interface{}{
"app_name": app, "version": version, "channel": channel, "platform": "universalPlatform", "arch": "universalArch",
}, "admin", name, viper.GetViper(), false).Key
writtenKeys = append(writtenKeys, key)
return key
}
assertNotStored := func(t *testing.T, key string) {
t.Helper()
_, err := uploader.StatObject(ctx, bucket, key)
assert.ErrorIs(t, err, storage.ErrObjectNotFound, "a rejected upload must not write %s", key)
}
multipartUploadAs := func(t *testing.T, bearer, data string, file updaterFeedFile) *httptest.ResponseRecorder {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", file.name)
require.NoError(t, err)
_, err = part.Write(file.content)
require.NoError(t, err)
require.NoError(t, writer.WriteField("data", data))
require.NoError(t, writer.Close())
req, err := http.NewRequest("POST", "/upload", body)
require.NoError(t, err)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+bearer)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
multipartUpload := func(t *testing.T, data string, file updaterFeedFile) *httptest.ResponseRecorder {
t.Helper()
return multipartUploadAs(t, token, data, file)
}
// assertStoredUnderAdmin checks that a successful upload is recorded and stored under the admin, whoever sent it.
assertStoredUnderAdmin := func(t *testing.T, w *httptest.ResponseRecorder, app, version, channel string, file updaterFeedFile) {
t.Helper()
doc := uploadedVersion(t, w)
assert.Equal(t, "admin", doc.Owner)
key := objectKey(app, version, channel, file.name)
require.Len(t, doc.Artifacts, 1)
assert.True(t, strings.HasSuffix(doc.Artifacts[0].Link, "/"+key), "link %s must point at %s", doc.Artifacts[0].Link, key)
stat, err := uploader.StatObject(ctx, bucket, key)
require.NoError(t, err, "object must be stored under the admin")
md := md5.Sum(file.content)
assert.Equal(t, hex.EncodeToString(md[:]), stat.MD5)
}
initOK := func(t *testing.T, data string, file updaterFeedFile) presignedInitResponse {
t.Helper()
w := doPresignedInit(t, router, token, data, presignedManifest([]updaterFeedFile{file}), nil)
require.Equal(t, http.StatusOK, w.Code, "init: %s", w.Body.String())
var resp presignedInitResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
status, body := putPresigned(t, resp, file.name, file.content)
require.Less(t, status, 300, "PUT: %s", body)
return resp
}
t.Run("multipart_rejects_app_outside_allowed_list", func(t *testing.T) {
file := artifact(forbiddenApp, "1.0.0")
w := multipartUpload(t, payload(forbiddenApp, "1.0.0", "nightly"), file)
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "access to this app")
assertNotStored(t, objectKey(forbiddenApp, "1.0.0", "nightly", file.name))
})
t.Run("multipart_upload_within_allowed_lists", func(t *testing.T) {
file := artifact(allowedApp, "0.9.0")
w := multipartUpload(t, payload(allowedApp, "0.9.0", "nightly"), file)
assertStoredUnderAdmin(t, w, allowedApp, "0.9.0", "nightly", file)
})
t.Run("api_token_multipart_upload", func(t *testing.T) {
req, err := http.NewRequest("POST", "/token/create", strings.NewReader(fmt.Sprintf(`{"name":"%s","allowed_apps":["%s"]}`, apiTokenName, appID)))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusCreated, w.Code, "create token: %s", w.Body.String())
var created map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &created))
apiToken, _ := created["token"].(string)
require.NotEmpty(t, apiToken)
file := artifact(allowedApp, "0.9.1")
w = multipartUploadAs(t, apiToken, payload(allowedApp, "0.9.1", "stable"), file)
assertStoredUnderAdmin(t, w, allowedApp, "0.9.1", "stable", file)
})
t.Run("init_rejects_app_outside_allowed_list", func(t *testing.T) {
file := artifact(forbiddenApp, "1.0.0")
w := doPresignedInit(t, router, token, payload(forbiddenApp, "1.0.0", "nightly"), presignedManifest([]updaterFeedFile{file}), nil)
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
count, err := pendingUploads.CountDocuments(ctx, bson.M{"username": teamUser})
require.NoError(t, err)
assert.Zero(t, count, "a rejected init must not create a pending upload")
})
t.Run("complete_rechecks_app_access_and_releases", func(t *testing.T) {
file := artifact(allowedApp, "1.0.0")
upload := initOK(t, payload(allowedApp, "1.0.0", "nightly"), file)
restore := revoke(t, "permissions.apps.allowed", appID)
w := doPresignedComplete(t, router, token, upload.UploadID)
restore()
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
var pending struct {
State string `bson:"state"`
}
require.NoError(t, pendingUploads.FindOne(ctx, bson.M{"_id": upload.UploadID}).Decode(&pending))
assert.Equal(t, "pending", pending.State, "a denied complete must stay retryable")
doc := uploadedVersion(t, doPresignedComplete(t, router, token, upload.UploadID))
assert.Equal(t, "admin", doc.Owner, "a team user's upload is stored under their admin")
})
t.Run("init_rejects_entities_outside_allowed_lists", func(t *testing.T) {
cases := []struct {
field, id, message string
}{
{"permissions.channels.allowed", channelID, "access to this channel"},
{"permissions.platforms.allowed", platformID, "access to this platform"},
{"permissions.archs.allowed", archID, "access to this architecture"},
}
for _, tc := range cases {
file := artifact(allowedApp, "1.1.0")
restore := revoke(t, tc.field, tc.id)
w := doPresignedInit(t, router, token, payload(allowedApp, "1.1.0", "nightly"), presignedManifest([]updaterFeedFile{file}), nil)
restore()
assert.Equal(t, http.StatusForbidden, w.Code, "%s: %s", tc.field, w.Body.String())
assert.Contains(t, w.Body.String(), tc.message, tc.field)
}
})
t.Run("complete_rechecks_channel_access_before_storage", func(t *testing.T) {
file := artifact(allowedApp, "1.2.0")
upload := initOK(t, payload(allowedApp, "1.2.0", "nightly"), file)
restore := revoke(t, "permissions.channels.allowed", channelID)
w := doPresignedComplete(t, router, token, upload.UploadID)
restore()
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "access to this channel")
assertNotStored(t, objectKey(allowedApp, "1.2.0", "nightly", file.name))
})
t.Run("multipart_rejects_channel_outside_allowed_list_before_storage", func(t *testing.T) {
file := artifact(allowedApp, "1.3.0")
restore := revoke(t, "permissions.channels.allowed", channelID)
w := multipartUpload(t, payload(allowedApp, "1.3.0", "nightly"), file)
restore()
assert.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "access to this channel")
assertNotStored(t, objectKey(allowedApp, "1.3.0", "nightly", file.name))
})
}
// func TestSquirrelReleases(t *testing.T) {
// router := gin.Default()
// handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
// router.GET("/update/:owner/:app/:channel/:platform/:arch/:version/RELEASES", func(c *gin.Context) {
// handler.SquirrelReleases(c)
// })
// // Define test scenarios for SquirrelReleases handler
// testScenarios := []struct {
// Owner string
// AppName string
// ChannelName string
// Platform string
// Arch string
// Version string
// ExpectedCode int
// TestName string
// ExpectedBody string
// }{
// {
// Owner: "admin",
// AppName: "updaters",
// ChannelName: "stable",
// Platform: "windows",
// Arch: "universalArch",
// Version: "0.0.1.137",
// ExpectedCode: http.StatusFound, // 302 redirect
// TestName: "SquirrelReleasesUpdateAvailable",
// ExpectedBody: "RELEASES FILE FOR WINDOWS sqirrel",
// },
// {
// Owner: "admin",
// AppName: "updaters",
// ChannelName: "stable",
// Platform: "windows",
// Arch: "universalArch",
// Version: "0.0.6.137",
// ExpectedCode: http.StatusFound, // 302 redirect
// TestName: "SquirrelReleasesUpdateNotAvailable",
// ExpectedBody: "RELEASES FILE FOR WINDOWS sqirrel",
// },
// // {
// // Owner: "admin",
// // AppName: "nonexistent",
// // ChannelName: "stable",
// // Platform: "windows",
// // Arch: "universalArch",
// // Version: "0.0.1.137",
// // ExpectedCode: http.StatusBadRequest, // Should return error for non-existent app
// // TestName: "SquirrelReleasesNonExistentApp",
// // },
// }
// // Run test scenarios
// for _, scenario := range testScenarios {
// t.Run(scenario.TestName, func(t *testing.T) {
// // Create URL with parameters
// url := fmt.Sprintf("/update/%s/%s/%s/%s/%s/%s/RELEASES",
// scenario.Owner,
// scenario.AppName,
// scenario.ChannelName,
// scenario.Platform,
// scenario.Arch,
// scenario.Version,
// )
// // Create request with proper host information
// req, err := http.NewRequest("GET", url, nil)
// if err != nil {
// t.Fatal(err)
// }
// // Create response recorder
// w := httptest.NewRecorder()
// // Serve the request
// router.ServeHTTP(w, req)
// // Log full response for debugging
// logrus.Infof("=== Test: %s ===", scenario.TestName)
// logrus.Infof("Status Code: %d", w.Code)
// logrus.Infof("Headers: %v", w.Header())
// logrus.Infof("Body: %s", w.Body.String())
// logrus.Infof("==================")
// // Check status code
// assert.Equal(t, scenario.ExpectedCode, w.Code, "Expected status code %d, got %d", scenario.ExpectedCode, w.Code)
// // For 302 Found (redirect), check the Location header and HTML body
// locationHeader := w.Header().Get("Location")
// logrus.Infof("Location Header: %s", locationHeader)
// if locationHeader != "" {
// // If Location header is set, that's the redirect URL
// // var expectedURL string
// // if url, exists := scenario.ExpectedJSON["url"]; exists {
// // expectedURL = url.(string)
// // } else if updateURL, exists := scenario.ExpectedJSON["update_url_yml"]; exists {
// // expectedURL = updateURL.(string)
// // } else {
// // t.Fatal("Neither 'url' nor 'update_url_yml' found in ExpectedJSON")
// // }
// // assert.Equal(t, expectedURL, locationHeader)
// body := checkFileContent(t, locationHeader)
// logrus.Infof("asserting that body: %s, contains: %s", body, scenario.ExpectedBody)
// assert.Contains(t, body, scenario.ExpectedBody)
// } else {
// // If no Location header, check HTML body for redirect link
// body := w.Body.String()
// logrus.Infof("Body in squirrel win releases: %s", body)
// // expectedURL := scenario.ExpectedJSON["url"].(string)
// // Check if the expected URL is present in the HTML body
// // assert.Contains(t, body, expectedURL)
// }
// // // For 302 redirects, check if Location header is present
// // if scenario.ExpectedCode == http.StatusFound {
// // locationHeader := w.Header().Get("Location")
// // assert.NotEmpty(t, locationHeader, "Location header should be present for 302 redirect")
// // // Log the redirect URL for debugging
// // logrus.Infof("Redirect URL for %s: %s", scenario.TestName, locationHeader)
// // }
// // // For error cases, check if error message is present
// // if scenario.ExpectedCode == http.StatusBadRequest {
// // var response map[string]interface{}
// // err := json.Unmarshal(w.Body.Bytes(), &response)
// // assert.NoError(t, err, "Should be able to parse error response as JSON")
// // assert.Contains(t, response, "error", "Error response should contain 'error' field")
// // }
// })
// }
// }
var cicdToken string
var cicdTokenID string
var cicdTokenSecondUserID string
var cicdTokenAdminAppID string
var cicdTokenSecondAdminAppID string
func TestTokenFlow01Create(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/token/create", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.CreateToken(c)
})
adminAppID := primitive.NewObjectID()
secondAdminAppID := primitive.NewObjectID()
collection := mongoDatabase.Collection("apps_meta")
_, err := collection.InsertMany(context.TODO(), []interface{}{
bson.M{"_id": adminAppID, "owner": "admin", "app_name": "token-flow-admin-app"},
bson.M{"_id": secondAdminAppID, "owner": "administrator", "app_name": "token-flow-admin-second-app"},
})
assert.NoError(t, err)
cicdTokenAdminAppID = adminAppID.Hex()
cicdTokenSecondAdminAppID = secondAdminAppID.Hex()
adminPayload := fmt.Sprintf(`{"name":"cicd-token-admin","allowed_apps":["%s"]}`, cicdTokenAdminAppID)
adminReq, err := http.NewRequest("POST", "/token/create", bytes.NewBufferString(adminPayload))
if err != nil {
t.Fatal(err)
}
adminReq.Header.Set("Content-Type", "application/json")
adminReq.Header.Set("Authorization", "Bearer "+authToken)
adminW := httptest.NewRecorder()
router.ServeHTTP(adminW, adminReq)
assert.Equal(t, http.StatusCreated, adminW.Code)
var adminResponse map[string]interface{}
err = json.Unmarshal(adminW.Body.Bytes(), &adminResponse)
if err != nil {
t.Fatal(err)
}
tokenValue, tokenExists := adminResponse["token"]
assert.True(t, tokenExists)
var ok bool
cicdToken, ok = tokenValue.(string)
require.True(t, ok, adminW.Body.String())
assert.NotEmpty(t, cicdToken)
tokenIDValue, tokenIDExists := adminResponse["id"]
assert.True(t, tokenIDExists)
cicdTokenID = tokenIDValue.(string)
assert.NotEmpty(t, cicdTokenID)
secondPayload := fmt.Sprintf(`{"name":"cicd-token-second-admin","allowed_apps":["%s"]}`, cicdTokenSecondAdminAppID)
secondReq, err := http.NewRequest("POST", "/token/create", bytes.NewBufferString(secondPayload))
if err != nil {
t.Fatal(err)
}
secondReq.Header.Set("Content-Type", "application/json")
secondReq.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
secondW := httptest.NewRecorder()
router.ServeHTTP(secondW, secondReq)
assert.Equal(t, http.StatusCreated, secondW.Code)
var secondResponse map[string]interface{}
err = json.Unmarshal(secondW.Body.Bytes(), &secondResponse)
if err != nil {
t.Fatal(err)
}
secondTokenValue, secondTokenExists := secondResponse["token"]
assert.True(t, secondTokenExists)
secondToken, ok := secondTokenValue.(string)
require.True(t, ok, secondW.Body.String())
assert.NotEmpty(t, secondToken)
assert.NoError(t, os.Setenv("cicdTokenSecondUser", secondToken))
secondTokenIDValue, secondTokenIDExists := secondResponse["id"]
assert.True(t, secondTokenIDExists)
cicdTokenSecondUserID = secondTokenIDValue.(string)
assert.NotEmpty(t, cicdTokenSecondUserID)
}
func TestTokenCreateWithPastExpirationDate(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/token/create", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.CreateToken(c)
})
adminPastExpiryAppID := primitive.NewObjectID()
_, err := mongoDatabase.Collection("apps_meta").InsertOne(context.TODO(), bson.M{
"_id": adminPastExpiryAppID,
"owner": "admin",
"app_name": "token-past-expiry-app",
})
assert.NoError(t, err)
defer func() {
_, cleanupErr := mongoDatabase.Collection("apps_meta").DeleteOne(context.TODO(), bson.M{"_id": adminPastExpiryAppID})
assert.NoError(t, cleanupErr)
}()
pastTime := time.Now().Add(-1 * time.Minute).UTC().Format(time.RFC3339Nano)
payload := fmt.Sprintf(`{"name":"token-with-past-expiry","allowed_apps":["%s"],"expires_at":"%s"}`, adminPastExpiryAppID.Hex(), pastTime)
req, err := http.NewRequest("POST", "/token/create", bytes.NewBufferString(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+authToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, `{"error":"expires_at must be in the future"}`, w.Body.String())
}
func TestTokenExpiresImmediatelyAndReturnsUnauthorized(t *testing.T) {
createRouter := gin.Default()
createRouter.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
createRouter.POST("/token/create", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.CreateToken(c)
})
adminShortExpiryAppID := primitive.NewObjectID()
_, err := mongoDatabase.Collection("apps_meta").InsertOne(context.TODO(), bson.M{
"_id": adminShortExpiryAppID,
"owner": "admin",
"app_name": "token-short-expiry-app",
})
assert.NoError(t, err)
defer func() {
_, cleanupErr := mongoDatabase.Collection("apps_meta").DeleteOne(context.TODO(), bson.M{"_id": adminShortExpiryAppID})
assert.NoError(t, cleanupErr)
}()
expiresSoon := time.Now().Add(250 * time.Millisecond).UTC().Format(time.RFC3339Nano)
createPayload := fmt.Sprintf(`{"name":"token-short-expiry","allowed_apps":["%s"],"expires_at":"%s"}`, adminShortExpiryAppID.Hex(), expiresSoon)
createReq, err := http.NewRequest("POST", "/token/create", bytes.NewBufferString(createPayload))
if err != nil {
t.Fatal(err)
}
createReq.Header.Set("Content-Type", "application/json")
createReq.Header.Set("Authorization", "Bearer "+authToken)
createW := httptest.NewRecorder()
createRouter.ServeHTTP(createW, createReq)
assert.Equal(t, http.StatusCreated, createW.Code)
var createResponse map[string]interface{}
err = json.Unmarshal(createW.Body.Bytes(), &createResponse)
if err != nil {
t.Fatal(err)
}
shortLivedToken, ok := createResponse["token"].(string)
assert.True(t, ok)
assert.NotEmpty(t, shortLivedToken)
shortLivedTokenID, ok := createResponse["id"].(string)
assert.True(t, ok)
defer func() {
deleteRouter := gin.Default()
deleteRouter.Use(utils.AuthMiddleware(mongoDatabase))
deleteRouter.DELETE("/token/delete", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.DeleteToken(c)
})
deleteReq, deleteErr := http.NewRequest("DELETE", "/token/delete", bytes.NewBufferString(fmt.Sprintf(`{"id":"%s"}`, shortLivedTokenID)))
assert.NoError(t, deleteErr)
deleteReq.Header.Set("Content-Type", "application/json")
deleteReq.Header.Set("Authorization", "Bearer "+authToken)
deleteW := httptest.NewRecorder()
deleteRouter.ServeHTTP(deleteW, deleteReq)
assert.Equal(t, http.StatusOK, deleteW.Code, deleteW.Body.String())
}()
time.Sleep(500 * time.Millisecond)
useRouter := gin.Default()
useRouter.Use(utils.AuthMiddleware(mongoDatabase))
useRouter.POST("/test/upload", utils.CheckPermission(utils.PermissionUpload, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": "ok"})
})
useReq, err := http.NewRequest("POST", "/test/upload", nil)
if err != nil {
t.Fatal(err)
}
useReq.Header.Set("Authorization", "Bearer "+shortLivedToken)
useW := httptest.NewRecorder()
useRouter.ServeHTTP(useW, useReq)
assert.Equal(t, http.StatusUnauthorized, useW.Code)
assert.Equal(t, `{"error":"token expired"}`, useW.Body.String())
}
func TestTokenMiddlewareFlowForBothTokens(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
router.POST("/test/app/create", utils.CheckPermission(utils.PermissionCreate, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": "ok"})
})
router.POST("/test/upload", utils.CheckPermission(utils.PermissionUpload, utils.ResourceApps, mongoDatabase), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": "ok"})
})
router.GET("/test/token/list", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": "ok"})
})
secondToken := os.Getenv("cicdTokenSecondUser")
assert.NotEmpty(t, cicdToken)
assert.NotEmpty(t, secondToken)
testTokens := []struct {
name string
token string
}{
{name: "first api token", token: cicdToken},
{name: "second api token", token: secondToken},
}
for _, tokenCase := range testTokens {
t.Run(tokenCase.name, func(t *testing.T) {
createReq, err := http.NewRequest("POST", "/test/app/create", nil)
if err != nil {
t.Fatal(err)
}
createReq.Header.Set("Authorization", "Bearer "+tokenCase.token)
createW := httptest.NewRecorder()
router.ServeHTTP(createW, createReq)
assert.Equal(t, http.StatusForbidden, createW.Code)
var createResponse map[string]interface{}
err = json.Unmarshal(createW.Body.Bytes(), &createResponse)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "API tokens are restricted to app upload", createResponse["error"])
uploadReq, err := http.NewRequest("POST", "/test/upload", nil)
if err != nil {
t.Fatal(err)
}
uploadReq.Header.Set("Authorization", "Bearer "+tokenCase.token)
uploadW := httptest.NewRecorder()
router.ServeHTTP(uploadW, uploadReq)
assert.Equal(t, http.StatusOK, uploadW.Code)
adminOnlyReq, err := http.NewRequest("GET", "/test/token/list", nil)
if err != nil {
t.Fatal(err)
}
adminOnlyReq.Header.Set("Authorization", "Bearer "+tokenCase.token)
adminOnlyW := httptest.NewRecorder()
router.ServeHTTP(adminOnlyW, adminOnlyReq)
assert.Equal(t, http.StatusForbidden, adminOnlyW.Code)
var adminOnlyResponse map[string]interface{}
err = json.Unmarshal(adminOnlyW.Body.Bytes(), &adminOnlyResponse)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "Admin JWT is required", adminOnlyResponse["error"])
})
}
}
func TestTokenFlow02List(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/token/list", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.ListTokens(c)
})
adminReq, err := http.NewRequest("GET", "/token/list", nil)
if err != nil {
t.Fatal(err)
}
adminReq.Header.Set("Authorization", "Bearer "+authToken)
adminW := httptest.NewRecorder()
router.ServeHTTP(adminW, adminReq)
assert.Equal(t, http.StatusOK, adminW.Code)
var adminResponse map[string]interface{}
err = json.Unmarshal(adminW.Body.Bytes(), &adminResponse)
if err != nil {
t.Fatal(err)
}
adminTokens, ok := adminResponse["tokens"].([]interface{})
assert.True(t, ok)
adminTokenFound := false
secondUserTokenVisibleForAdmin := false
for _, token := range adminTokens {
tokenMap, mapOk := token.(map[string]interface{})
if !mapOk {
continue
}
if tokenMap["id"] == cicdTokenID {
adminTokenFound = true
}
if tokenMap["id"] == cicdTokenSecondUserID {
secondUserTokenVisibleForAdmin = true
}
}
assert.True(t, adminTokenFound)
assert.False(t, secondUserTokenVisibleForAdmin)
secondReq, err := http.NewRequest("GET", "/token/list", nil)
if err != nil {
t.Fatal(err)
}
secondReq.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
secondW := httptest.NewRecorder()
router.ServeHTTP(secondW, secondReq)
assert.Equal(t, http.StatusOK, secondW.Code)
var secondResponse map[string]interface{}
err = json.Unmarshal(secondW.Body.Bytes(), &secondResponse)
if err != nil {
t.Fatal(err)
}
secondTokens, ok := secondResponse["tokens"].([]interface{})
assert.True(t, ok)
secondTokenFound := false
adminTokenVisibleForSecondUser := false
for _, token := range secondTokens {
tokenMap, mapOk := token.(map[string]interface{})
if !mapOk {
continue
}
if tokenMap["id"] == cicdTokenSecondUserID {
secondTokenFound = true
}
if tokenMap["id"] == cicdTokenID {
adminTokenVisibleForSecondUser = true
}
}
assert.True(t, secondTokenFound)
assert.False(t, adminTokenVisibleForSecondUser)
assert.NotEmpty(t, os.Getenv("cicdTokenSecondUser"))
}
func TestTokenFlow03Delete(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/token/delete", utils.AdminOnlyMiddleware(mongoDatabase), func(c *gin.Context) {
appHandler.DeleteToken(c)
})
adminPayload := fmt.Sprintf(`{"id":"%s"}`, cicdTokenID)
adminReq, err := http.NewRequest("DELETE", "/token/delete", bytes.NewBufferString(adminPayload))
if err != nil {
t.Fatal(err)
}
adminReq.Header.Set("Content-Type", "application/json")
adminReq.Header.Set("Authorization", "Bearer "+authToken)
adminW := httptest.NewRecorder()
router.ServeHTTP(adminW, adminReq)
assert.Equal(t, http.StatusOK, adminW.Code)
secondPayload := fmt.Sprintf(`{"id":"%s"}`, cicdTokenSecondUserID)
secondReq, err := http.NewRequest("DELETE", "/token/delete", bytes.NewBufferString(secondPayload))
if err != nil {
t.Fatal(err)
}
secondReq.Header.Set("Content-Type", "application/json")
secondReq.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
secondW := httptest.NewRecorder()
router.ServeHTTP(secondW, secondReq)
assert.Equal(t, http.StatusOK, secondW.Code)
idsForCleanup := []primitive.ObjectID{}
if cicdTokenAdminAppID != "" {
appID, parseErr := primitive.ObjectIDFromHex(cicdTokenAdminAppID)
assert.NoError(t, parseErr)
if parseErr == nil {
idsForCleanup = append(idsForCleanup, appID)
}
}
if cicdTokenSecondAdminAppID != "" {
appID, parseErr := primitive.ObjectIDFromHex(cicdTokenSecondAdminAppID)
assert.NoError(t, parseErr)
if parseErr == nil {
idsForCleanup = append(idsForCleanup, appID)
}
}
if len(idsForCleanup) > 0 {
_, err = mongoDatabase.Collection("apps_meta").DeleteMany(context.TODO(), bson.M{"_id": bson.M{"$in": idsForCleanup}})
assert.NoError(t, err)
}
}
func TestUpdateSpecificAppWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the update endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/apps/update", func(c *gin.Context) {
handler.UpdateSpecificApp(c)
})
// Create a file to update (you can replace this with a test file path).
filePaths := []string{"LICENSE", "LICENSE"}
for _, filePath := range filePaths {
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
combinations := []struct {
ID string
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
Changelog string
}{
{uploadedAppIDs[1], "0.0.2.137", "nightly", true, true, "universalPlatform", "universalArch", "### Changelog"},
}
// Iterate through the combinations and update the file for each combination.
for _, combo := range combinations {
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
// Create a POST request for the update endpoint with the current combination.
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"id": "%s", "app_name": "testapp", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s", "changelog": "%s"}`, combo.ID, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch, combo.Changelog)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// logrus.Infoln("Body: ", body)
req, err := http.NewRequest("POST", "/apps/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code. Platform names are scoped to the owner, so the
// first admin's platform does not exist for the second one.
assert.Equal(t, http.StatusBadRequest, w.Code)
expected := `{"error":"wrong name of platform. Platform does not exist"}`
assert.Equal(t, expected, w.Body.String())
}
}
}
func TestUpdateSpecificApp(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the update endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/apps/update", func(c *gin.Context) {
handler.UpdateSpecificApp(c)
})
// Create a file to update (you can replace this with a test file path).
filePaths := []string{"LICENSE", "LICENSE"}
for _, filePath := range filePaths {
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
combinations := []struct {
ID string
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
Changelog string
}{
{uploadedAppIDs[1], "0.0.2.137", "nightly", true, true, "universalPlatform", "universalArch", "### Changelog"},
}
// Iterate through the combinations and update the file for each combination.
for _, combo := range combinations {
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// LICENSE is already stored for this version; re-sending it is rejected with 409
// (TestUploadRejectsBeforeWriting), so only the metadata is updated here.
// Create a POST request for the update endpoint with the current combination.
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"id": "%s", "app_name": "testapp", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s", "changelog": "%s"}`, combo.ID, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch, combo.Changelog)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// logrus.Infoln("Body: ", body)
req, err := http.NewRequest("POST", "/apps/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"updatedResult.Updated":true}`
assert.Equal(t, expected, w.Body.String())
}
}
}
func TestSearch(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.5.137",
Channel: "stable",
Published: false,
Critical: false,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.5.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.3.137",
Channel: "nightly",
Published: false,
Critical: false,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.3.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFilterSearchWithChannel(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&channel=nightly", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
fmt.Println(w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.3.137",
Channel: "nightly",
Published: false,
Critical: false,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.3.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFilterSearchWithChannelAndPublished(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&channel=stable&published=true", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
fmt.Println(w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFilterSearchWithChannelAndPublishedAndCritical(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&channel=stable&published=true&critical=true", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
fmt.Println(w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFilterSearchWithChannelAndPublishedAndCriticalAndPlatform(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&channel=nightly&published=true&critical=true&platform=universalPlatform", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
fmt.Println(w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFilterSearchWithChannelAndPublishedAndCriticalAndPlatformAndArch(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&channel=stable&published=true&critical=true&platform=universalPlatform&arch=universalArch", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
fmt.Println(w.Body.String())
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestSearchOnlyPublished(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&published=true", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestSearchOnlyCritical(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&critical=true", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestSearchOnlyUniversalPlatform(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /search endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/search", func(c *gin.Context) {
handler.GetAppByName(c)
})
// Create a POST request for the /search endpoint.
req, err := http.NewRequest("GET", "/search?app_name=testapp&platform=universalPlatform", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
// Define the expected JSON response as a slice of AppInfo.
type AppInfo struct {
ID string `json:"ID"`
AppID string `json:"AppID"`
AppName string `json:"AppName"`
Version string `json:"Version"`
Channel string `json:"Channel"`
Published bool `json:"Published"`
Critical bool `json:"Critical"`
Artifacts []model.SpecificArtifactsWithoutIDs `json:"Artifacts" bson:"artifacts"`
Changelog []model.Changelog `json:"Changelog" bson:"changelog"`
Updated_at string `json:"Updated_at"`
}
type AppResponse struct {
Items []AppInfo `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
expected := []AppInfo{
{
AppName: "testapp",
Version: "0.0.5.137",
Channel: "stable",
Published: false,
Critical: false,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.5.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.4.137",
Channel: "stable",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.4.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.3.137",
Channel: "nightly",
Published: false,
Critical: false,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.3.137",
Changes: "",
Date: time.Now().Format("2006-01-02"),
},
},
},
{
AppName: "testapp",
Version: "0.0.2.137",
Channel: "nightly",
Published: true,
Critical: true,
Artifacts: []model.SpecificArtifactsWithoutIDs{
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".dmg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: ".pkg",
},
{
Platform: "universalPlatform",
Arch: "universalArch",
Package: "",
},
},
Changelog: []model.Changelog{
{
Version: "0.0.2.137",
Changes: "### Changelog",
Date: time.Now().Format("2006-01-02"),
},
},
},
}
var actual AppResponse
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the relevant fields (AppName, Version, Channel, Changelog) for each item in the response.
if len(actual.Items) != len(expected) {
t.Fatalf("Expected %d apps but got %d", len(expected), len(actual.Items))
}
for i, expectedApp := range expected {
assert.Equal(t, expectedApp.AppName, actual.Items[i].AppName)
assert.Equal(t, expectedApp.Version, actual.Items[i].Version)
assert.Equal(t, expectedApp.Channel, actual.Items[i].Channel)
assert.Equal(t, expectedApp.Published, actual.Items[i].Published)
if len(expectedApp.Artifacts) != len(actual.Items[i].Artifacts) {
t.Fatalf("Expected %d artifacts for app %s with version %s but got %d", len(expectedApp.Artifacts), expectedApp.ID, expectedApp.Version, len(actual.Items[i].Artifacts))
}
for j, expectedArtifact := range expectedApp.Artifacts {
assert.Equal(t, expectedArtifact.Platform, actual.Items[i].Artifacts[j].Platform)
assert.Equal(t, expectedArtifact.Arch, actual.Items[i].Artifacts[j].Arch)
assert.Equal(t, expectedArtifact.Package, actual.Items[i].Artifacts[j].Package)
}
if len(expectedApp.Changelog) != len(actual.Items[i].Changelog) {
t.Fatalf("Expected %d changelog entries for app %s but got %d", len(expectedApp.Changelog), expectedApp.ID, len(actual.Items[i].Changelog))
}
for c, expectedChanges := range expectedApp.Changelog {
assert.Equal(t, expectedChanges.Version, actual.Items[i].Changelog[c].Version)
assert.Equal(t, expectedChanges.Changes, actual.Items[i].Changelog[c].Changes)
assert.Equal(t, expectedChanges.Date, actual.Items[i].Changelog[c].Date)
}
}
assert.Equal(t, 1, actual.Page)
assert.Equal(t, 9, actual.Limit)
assert.Equal(t, len(expected), actual.Total)
}
func TestFetchkLatestVersionOfApp(t *testing.T) {
router := gin.Default()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/apps/latest", func(c *gin.Context) {
handler.FetchLatestVersionOfApp(c)
})
// Define test scenarios.
testScenarios := []struct {
AppName string
ChannelName string
ExpectedJSON map[string]interface{}
ExpectedCode int
Platform string
Arch string
TestName string
Owner string
}{
{
AppName: "testapp",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"nightly": map[string]interface{}{
"universalPlatform": map[string]interface{}{
"universalArch": map[string]interface{}{
"dmg": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.dmg"),
"version": "0.0.2.137",
},
"pkg": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.pkg"),
"version": "0.0.2.137",
},
"no-extension": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137"),
"version": "0.0.2.137",
},
},
},
},
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
},
{
AppName: "testapp",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"stable": map[string]interface{}{
"universalPlatform": map[string]interface{}{
"universalArch": map[string]interface{}{
"dmg": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.dmg"),
"version": "0.0.4.137",
},
"pkg": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.pkg"),
"version": "0.0.4.137",
},
"no-extension": map[string]interface{}{
"url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137"),
"version": "0.0.4.137",
},
},
},
},
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "StableUpdateAvailable",
Owner: "admin",
},
}
for _, scenario := range testScenarios {
t.Run(scenario.TestName, func(t *testing.T) {
w := httptest.NewRecorder()
// Create a GET request for checking the version.
req, err := http.NewRequest("GET", fmt.Sprintf("/apps/latest?app_name=%s&channel=%s&platform=%s&arch=%s&owner=%s", scenario.AppName, scenario.ChannelName, scenario.Platform, scenario.Arch, scenario.Owner), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(utils.DownloadTokenHeader, testappDownloadToken(t, scenario.ChannelName))
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, scenario.ExpectedCode, w.Code)
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the response with the expected values.
assert.Equal(t, scenario.ExpectedJSON, actual)
})
}
}
func TestCheckVersion(t *testing.T) {
router := gin.Default()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/checkVersion", func(c *gin.Context) {
handler.FindLatestVersion(c)
})
server := httptest.NewServer(router)
defer server.Close()
sdkClient := faynosync.NewClient(faynosync.Config{
EdgeURL: s3Endpoint,
BaseURL: server.URL,
})
decodeScenarioValue := func(raw string) string {
decoded, err := url.QueryUnescape(raw)
if err != nil {
return raw
}
return decoded
}
// Define test scenarios.
testScenarios := []struct {
AppName string
Version string
ChannelName string
ExpectedJSON map[string]interface{}
ExpectedCode int
Published bool
Platform string
Arch string
TestName string
Owner string
}{
{
AppName: "public%20testapp",
Version: "0.0.1.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
// "changelog": "### Changelog\n",
"update_available": false,
"critical": false,
"possible_rollback": false,
"update_url_dmg": fmt.Sprintf("%s/%s", s3Endpoint, "public%20testapp-admin/nightly/universalPlatform/universalArch/public%20testapp-0.0.1.137.dmg"),
"update_url_pkg": fmt.Sprintf("%s/%s", s3Endpoint, "public%20testapp-admin/nightly/universalPlatform/universalArch/public%20testapp-0.0.1.137.pkg"),
"update_url": fmt.Sprintf("%s/%s", s3Endpoint, "public%20testapp-admin/nightly/universalPlatform/universalArch/public%20testapp-0.0.1.137"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
},
{
AppName: "testapp",
Version: "0.0.2.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"update_available": false,
"critical": false,
"possible_rollback": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.dmg"),
"update_url_pkg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.pkg"),
"update_url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
},
{
AppName: "testapp",
Version: "0.0.3.137",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"critical": true,
"possible_rollback": true,
"changelog": "### Changelog\n",
"update_available": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.dmg"),
"update_url_pkg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137.pkg"),
"update_url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.137"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
},
{
AppName: "testapp",
Version: "0.0.4.137",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"update_available": false,
"critical": false,
"possible_rollback": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.dmg"),
"update_url_pkg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.pkg"),
"update_url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "StableUpdateAvailable",
Owner: "admin",
},
{
AppName: "testapp",
Version: "0.0.5.137",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"update_available": false,
"critical": true,
"possible_rollback": true,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.dmg"),
"update_url_pkg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137.pkg"),
"update_url": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.137"),
},
ExpectedCode: http.StatusOK,
// Published: false,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "StableUpdateAvailable",
Owner: "admin",
},
}
for _, scenario := range testScenarios {
t.Run(scenario.TestName, func(t *testing.T) {
w := httptest.NewRecorder()
// Create a GET request for checking the version.
req, err := http.NewRequest("GET", fmt.Sprintf("/checkVersion?app_name=%s&version=%s&channel=%s&platform=%s&arch=%s&owner=%s", scenario.AppName, scenario.Version, scenario.ChannelName, scenario.Platform, scenario.Arch, scenario.Owner), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(utils.DownloadTokenHeader, testappDownloadToken(t, scenario.ChannelName))
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, scenario.ExpectedCode, w.Code)
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the response with the expected values.
assert.Equal(t, scenario.ExpectedJSON, actual)
checkOpts := faynosync.CheckOptions{
Owner: decodeScenarioValue(scenario.Owner),
AppName: decodeScenarioValue(scenario.AppName),
Version: decodeScenarioValue(scenario.Version),
Channel: decodeScenarioValue(scenario.ChannelName),
Platform: decodeScenarioValue(scenario.Platform),
Arch: decodeScenarioValue(scenario.Arch),
DownloadToken: scenarioDownloadToken(t, decodeScenarioValue(scenario.AppName),
decodeScenarioValue(scenario.ChannelName)),
}
expectedSource := faynosync.SourceAPI
if scenario.AppName == "public%20testapp" && scenario.Version == "0.0.1.137" && scenario.ChannelName == "nightly" {
expectedSource = faynosync.SourceEdge
// The API publishes the CDN copy in the background, so the edge serves it shortly after the response.
require.Eventually(t, func() bool {
resp, err := sdkClient.CheckForUpdates(context.Background(), checkOpts)
return err == nil && resp.Source == faynosync.SourceEdge
}, 5*time.Second, 50*time.Millisecond)
}
sdkResp, err := sdkClient.CheckForUpdates(context.Background(), checkOpts)
require.NoError(t, err)
assertSDKMatchesScenario(t, scenario.ExpectedJSON, sdkResp)
require.Equal(t, expectedSource, sdkResp.Source)
})
}
}
func TestUpdateSpecificAppWithCDNPublishFalseToCheckS3ObjectDeleted(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the update endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/apps/update", func(c *gin.Context) {
handler.UpdateSpecificApp(c)
})
// Create a file to update (you can replace this with a test file path).
filePaths := []string{"LICENSE", "LICENSE"}
for _, filePath := range filePaths {
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
combinations := []struct {
ID string
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
Changelog string
}{
{uploadedAppIDs[0], "0.0.1.137", "nightly", false, true, "universalPlatform", "universalArch", "### Changelog"},
}
// Iterate through the combinations and update the file for each combination.
for _, combo := range combinations {
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// LICENSE is already stored for this version; re-sending it is rejected with 409
// (TestUploadRejectsBeforeWriting), so only the metadata is updated here.
// Create a POST request for the update endpoint with the current combination.
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"id": "%s", "app_name": "public testapp", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s", "changelog": "%s"}`, combo.ID, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch, combo.Changelog)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// logrus.Infoln("Body: ", body)
req, err := http.NewRequest("POST", "/apps/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"updatedResult.Updated":true}`
assert.Equal(t, expected, w.Body.String())
cdnResponseURL, err := url.Parse(s3Endpoint)
require.NoError(t, err)
cdnResponseURL = cdnResponseURL.JoinPath(
"responses",
"admin",
"public testapp",
combo.ChannelName,
combo.Platform,
combo.Arch,
combo.AppVersion+".json",
)
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
cdnReq, err := http.NewRequestWithContext(ctx, http.MethodGet, cdnResponseURL.String(), nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(cdnReq)
require.NoError(t, err)
defer resp.Body.Close()
// Providers that keep s3:ListBucket private answer a missing key with 403 instead of 404.
assert.Contains(t, []int{http.StatusNotFound, http.StatusForbidden}, resp.StatusCode, "CDN response JSON should be deleted after publish=false update: %s", cdnResponseURL.String())
}
}
}
// bulkDeleteQuery builds the "?id=a&id=b" form of the delete request.
func bulkDeleteQuery(appIDs []string) string {
return "/apps/delete?id=" + strings.Join(appIDs, "&id=")
}
func TestMultipleDeleteOfDifferentApps(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
// uploadedAppIDs[0] is a version of "public testapp", the rest belong to
// "testapp", and one request may only touch a single application.
req, err := http.NewRequest("DELETE", bulkDeleteQuery([]string{uploadedAppIDs[0], uploadedAppIDs[1]}), nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusBadRequest, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, "all versions must belong to the same application", response["error"])
assert.ElementsMatch(t, []interface{}{"public testapp", "testapp"}, response["apps"])
}
func TestMultipleDeleteWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
req, err := http.NewRequest("DELETE", bulkDeleteQuery([]string{uploadedAppIDs[1], uploadedAppIDs[2]}), nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusForbidden, w.Code)
expected := fmt.Sprintf(`{"error":"you don't have permission to delete these items","forbidden":["%s","%s"]}`, uploadedAppIDs[1], uploadedAppIDs[2])
assert.Equal(t, expected, w.Body.String())
}
func TestMultipleDelete(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
// Every version of "testapp" goes away in a single request. The rejected
// requests above must have left all of them in place.
publicAppID, testAppIDs := uploadedAppIDs[0], uploadedAppIDs[1:]
req, err := http.NewRequest("DELETE", bulkDeleteQuery(testAppIDs), nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, float64(len(testAppIDs)), response["deleteSpecificAppResult.DeletedCount"])
assert.Equal(t, "testapp", response["app_name"])
assert.Nil(t, response["orphaned_links"])
deleted, deletedExists := response["deleted"].([]interface{})
assert.True(t, deletedExists)
var deletedIDs []string
for _, item := range deleted {
version, ok := item.(map[string]interface{})
assert.True(t, ok)
deletedIDs = append(deletedIDs, version["id"].(string))
}
assert.ElementsMatch(t, testAppIDs, deletedIDs)
// A single id keeps the original response of this endpoint.
w = httptest.NewRecorder()
req, err = http.NewRequest("DELETE", "/apps/delete?id="+publicAppID, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"deleteSpecificAppResult.DeletedCount":1}`
assert.Equal(t, expected, w.Body.String())
}
func TestMultipleDeleteWithUpdaters(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
// Iterate over the uploadedAppIDs and send a DELETE request for each ID.
for _, appID := range uploadedAppIDsWithUpdaters {
w := httptest.NewRecorder()
req, err := http.NewRequest("DELETE", "/apps/delete?id="+appID, nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code for each request.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"deleteSpecificAppResult.DeletedCount":1}`
assert.Equal(t, expected, w.Body.String())
}
}
var uploadedAppIDsWithSameExtension []string
func TestMultipleUploadWithSameExtension(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the upload endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/upload", func(c *gin.Context) {
handler.UploadApp(c)
})
// Create a file to upload (you can replace this with a test file path).
filePaths := []string{"testapp.dmg"}
for _, filePath := range filePaths {
file, err := os.Open(filePath)
if err != nil {
t.Fatal(err)
}
defer file.Close()
combinations := []struct {
AppVersion string
ChannelName string
Published bool
Critical bool
Platform string
Arch string
}{
{"0.0.1.138", "nightly", true, false, "secondPlatform", "secondArch"},
{"0.0.2.138", "nightly", true, false, "universalPlatform", "secondArch"},
{"0.0.2.138", "nightly", true, false, "universalPlatform", "universalArch"},
{"0.0.4.138", "stable", true, true, "universalPlatform", "universalArch"},
{"0.0.4.138", "stable", true, true, "secondPlatform", "universalArch"},
}
logrus.Infof("Initial combinations: %v", combinations)
// Iterate through the combinations and upload the file for each combination.
for _, combo := range combinations {
logrus.Infof("Uploading payload for: %v", combo)
w := httptest.NewRecorder()
// Reset the request body for each iteration.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(part, file)
if err != nil {
t.Fatal(err)
}
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"app_name": "testapp", "version": "%s", "channel": "%s", "publish": %v, "critical": %v, "platform": "%s", "arch": "%s"}`, combo.AppVersion, combo.ChannelName, combo.Published, combo.Critical, combo.Platform, combo.Arch)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form
err = writer.Close()
if err != nil {
t.Fatal(err)
}
logrus.Infoln("Payload: ", payload)
// Create a POST request for the /upload endpoint with the current combination.
req, err := http.NewRequest("POST", "/upload", body)
if err != nil {
t.Fatal(err)
}
// Set the Content-Type header for multipart/form-data.
req.Header.Set("Content-Type", writer.FormDataContentType())
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authToken)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
id, idExists := response["uploadResult.Uploaded"]
assert.True(t, idExists)
// Check if the id already exists in the uploadedAppIDsWithSameExtension array
exists := false
for _, val := range uploadedAppIDsWithSameExtension {
if val == id {
exists = true
break
}
}
// If id does not exist in the array, append it
if !exists {
uploadedAppIDsWithSameExtension = append(uploadedAppIDsWithSameExtension, id.(string))
}
assert.True(t, idExists)
assert.NotEmpty(t, id.(string))
}
}
}
func TestCheckVersionWithSameExtensionArtifactsAndDiffPlatformsArchs(t *testing.T) {
router := gin.Default()
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/checkVersion", func(c *gin.Context) {
handler.FindLatestVersion(c)
})
server := httptest.NewServer(router)
defer server.Close()
sdkClient := faynosync.NewClient(faynosync.Config{
BaseURL: server.URL,
})
decodeScenarioValue := func(raw string) string {
decoded, err := url.QueryUnescape(raw)
if err != nil {
return raw
}
return decoded
}
// Define test scenarios.
testScenarios := []struct {
AppName string
Version string
ChannelName string
ExpectedJSON map[string]interface{}
ExpectedCode int
Published bool
Platform string
Arch string
TestName string
Owner string
DeviceID string
}{
{
AppName: "testapp",
Version: "0.0.1.138",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"update_available": true,
"critical": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FsecondArch%2Ftestapp-0.0.2.138.dmg"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "secondArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
DeviceID: "device-001",
},
{
AppName: "testapp",
Version: "0.0.1.138",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"update_available": true,
"critical": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.2.138.dmg"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
DeviceID: "device-002",
},
{
AppName: "testapp",
Version: "0.0.3.138",
ChannelName: "nightly",
ExpectedJSON: map[string]interface{}{
"critical": false,
"possible_rollback": true,
"update_available": false,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fnightly%2FsecondPlatform%2FsecondArch%2Ftestapp-0.0.1.138.dmg"),
},
ExpectedCode: http.StatusOK,
Platform: "secondPlatform",
Arch: "secondArch",
TestName: "NightlyUpdateAvailable",
Owner: "admin",
DeviceID: "device-003",
},
{
AppName: "testapp",
Version: "0.0.3.138",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"update_available": true,
"critical": true,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FuniversalPlatform%2FuniversalArch%2Ftestapp-0.0.4.138.dmg"),
},
ExpectedCode: http.StatusOK,
Platform: "universalPlatform",
Arch: "universalArch",
TestName: "StableUpdateAvailable",
Owner: "admin",
DeviceID: "device-004",
},
{
AppName: "testapp",
Version: "0.0.3.138",
ChannelName: "stable",
ExpectedJSON: map[string]interface{}{
"update_available": true,
"critical": true,
"update_url_dmg": fmt.Sprintf("%s/%s%s", apiUrl, "download?key=", "testapp-admin%2Fstable%2FsecondPlatform%2FuniversalArch%2Ftestapp-0.0.4.138.dmg"),
},
ExpectedCode: http.StatusOK,
// Published: false,
Platform: "secondPlatform",
Arch: "universalArch",
TestName: "StableUpdateAvailable",
Owner: "admin",
DeviceID: "device-005",
},
}
for _, scenario := range testScenarios {
t.Run(scenario.TestName, func(t *testing.T) {
w := httptest.NewRecorder()
// Create a GET request for checking the version.
req, err := http.NewRequest("GET", fmt.Sprintf("/checkVersion?app_name=%s&version=%s&channel=%s&platform=%s&arch=%s&owner=%s", scenario.AppName, scenario.Version, scenario.ChannelName, scenario.Platform, scenario.Arch, scenario.Owner), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set(utils.DownloadTokenHeader, testappDownloadToken(t, scenario.ChannelName))
req.Header.Set("X-Device-ID", scenario.DeviceID)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, scenario.ExpectedCode, w.Code)
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatal(err)
}
// Compare the response with the expected values.
assert.Equal(t, scenario.ExpectedJSON, actual)
sdkResp, err := sdkClient.CheckForUpdates(
context.Background(),
faynosync.CheckOptions{
Owner: decodeScenarioValue(scenario.Owner),
AppName: decodeScenarioValue(scenario.AppName),
Version: decodeScenarioValue(scenario.Version),
Channel: decodeScenarioValue(scenario.ChannelName),
Platform: decodeScenarioValue(scenario.Platform),
Arch: decodeScenarioValue(scenario.Arch),
DeviceID: decodeScenarioValue(scenario.DeviceID),
DownloadToken: scenarioDownloadToken(t, decodeScenarioValue(scenario.AppName),
decodeScenarioValue(scenario.ChannelName)),
},
)
require.NoError(t, err)
assertSDKMatchesScenario(t, scenario.ExpectedJSON, sdkResp)
})
}
}
func TestTelemetryWithVariousParams(t *testing.T) {
if !viper.GetBool("ENABLE_TELEMETRY") || redisClient == nil {
t.Skip("telemetry integration tests require ENABLE_TELEMETRY=true and Redis connection")
}
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/telemetry", func(c *gin.Context) {
handler.GetTelemetry(c)
})
endDate := time.Now().UTC().Truncate(24 * time.Hour)
startDate := endDate.AddDate(0, 0, -7)
dateRange, dailyStats := generateDateRangeAndStats(startDate, 8)
scenarios := []struct {
Name string
QueryParams string
ExpectedCode int
ExpectedJSON map[string]interface{}
}{
{
Name: "Nightly universalPlatform + secondArch",
QueryParams: "range=week&apps=testapp&channels=nightly&platforms=universalPlatform&architectures=secondArch",
ExpectedCode: http.StatusOK,
ExpectedJSON: map[string]interface{}{
"date": startDate.Format("2006-01-02"),
"date_range": dateRange,
"admin": "admin",
"summary": map[string]interface{}{
"total_requests": float64(10),
"unique_clients": float64(1),
"clients_using_latest_version": float64(0),
"clients_outdated": float64(1),
"total_active_apps": float64(1),
},
"versions": map[string]interface{}{
"used_versions_count": float64(1),
"known_versions": []interface{}{
"0.0.1.138", "0.0.3.138",
},
"usage": []interface{}{
map[string]interface{}{
"version": "0.0.1.138",
"client_count": float64(1),
},
},
},
"platforms": []interface{}{
map[string]interface{}{
"platform": "universalPlatform",
"client_count": float64(1),
},
},
"architectures": []interface{}{
map[string]interface{}{
"arch": "secondArch",
"client_count": float64(1),
},
},
"channels": []interface{}{
map[string]interface{}{
"channel": "nightly",
"client_count": float64(1),
},
},
"daily_stats": []interface{}{
map[string]interface{}{"date": dailyStats[0], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[1], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[2], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[3], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[4], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[5], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[6], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[7], "total_requests": float64(10), "unique_clients": float64(1), "clients_using_latest_version": float64(0), "clients_outdated": float64(1)},
},
},
},
{
Name: "Nightly universalPlatform + universalArch",
QueryParams: "range=week&apps=testapp&channels=nightly&platforms=universalPlatform&architectures=universalArch",
ExpectedCode: http.StatusOK,
ExpectedJSON: map[string]interface{}{
"date": startDate.Format("2006-01-02"),
"date_range": dateRange,
"admin": "admin",
"summary": map[string]interface{}{
"total_requests": float64(10),
"unique_clients": float64(1),
"clients_using_latest_version": float64(0),
"clients_outdated": float64(1),
"total_active_apps": float64(1),
},
"versions": map[string]interface{}{
"used_versions_count": float64(1),
"known_versions": []interface{}{
"0.0.1.138", "0.0.3.138",
},
"usage": []interface{}{
map[string]interface{}{
"version": "0.0.1.138",
"client_count": float64(1),
},
},
},
"platforms": []interface{}{
map[string]interface{}{
"platform": "universalPlatform",
"client_count": float64(1),
},
},
"architectures": []interface{}{
map[string]interface{}{
"arch": "universalArch",
"client_count": float64(1),
},
},
"channels": []interface{}{
map[string]interface{}{
"channel": "nightly",
"client_count": float64(1),
},
},
"daily_stats": []interface{}{
map[string]interface{}{"date": dailyStats[0], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[1], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[2], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[3], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[4], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[5], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[6], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[7], "total_requests": float64(10), "unique_clients": float64(1), "clients_using_latest_version": float64(0), "clients_outdated": float64(1)},
},
},
},
{
Name: "Stable secondPlatform + universalArch",
QueryParams: "range=week&apps=testapp&channels=stable&platforms=secondPlatform&architectures=universalArch",
ExpectedCode: http.StatusOK,
ExpectedJSON: map[string]interface{}{
"date": startDate.Format("2006-01-02"),
"date_range": dateRange,
"admin": "admin",
"summary": map[string]interface{}{
"total_requests": float64(10),
"unique_clients": float64(1),
"clients_using_latest_version": float64(0),
"clients_outdated": float64(1),
"total_active_apps": float64(1),
},
"versions": map[string]interface{}{
"used_versions_count": float64(1),
"known_versions": []interface{}{
"0.0.1.138", "0.0.3.138",
},
"usage": []interface{}{
map[string]interface{}{
"version": "0.0.3.138",
"client_count": float64(1),
},
},
},
"platforms": []interface{}{
map[string]interface{}{
"platform": "secondPlatform",
"client_count": float64(1),
},
},
"architectures": []interface{}{
map[string]interface{}{
"arch": "universalArch",
"client_count": float64(1),
},
},
"channels": []interface{}{
map[string]interface{}{
"channel": "stable",
"client_count": float64(1),
},
},
"daily_stats": []interface{}{
map[string]interface{}{"date": dailyStats[0], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[1], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[2], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[3], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[4], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[5], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[6], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[7], "total_requests": float64(10), "unique_clients": float64(1), "clients_using_latest_version": float64(0), "clients_outdated": float64(1)},
},
},
},
{
Name: "Newer version — stable universal",
QueryParams: "range=week&apps=testapp&channels=stable&platforms=universalPlatform&architectures=universalArch",
ExpectedCode: http.StatusOK,
ExpectedJSON: map[string]interface{}{
"date": startDate.Format("2006-01-02"),
"date_range": dateRange,
"admin": "admin",
"summary": map[string]interface{}{
"total_requests": float64(10),
"unique_clients": float64(1),
"clients_using_latest_version": float64(0),
"clients_outdated": float64(1),
"total_active_apps": float64(1),
},
"versions": map[string]interface{}{
"used_versions_count": float64(1),
"known_versions": []interface{}{
"0.0.1.138", "0.0.3.138",
},
"usage": []interface{}{
map[string]interface{}{
"version": "0.0.3.138",
"client_count": float64(1),
},
},
},
"platforms": []interface{}{
map[string]interface{}{
"platform": "universalPlatform",
"client_count": float64(1),
},
},
"architectures": []interface{}{
map[string]interface{}{
"arch": "universalArch",
"client_count": float64(1),
},
},
"channels": []interface{}{
map[string]interface{}{
"channel": "stable",
"client_count": float64(1),
},
},
"daily_stats": []interface{}{
map[string]interface{}{"date": dailyStats[0], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[1], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[2], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[3], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[4], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[5], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[6], "total_requests": float64(0), "unique_clients": float64(0), "clients_using_latest_version": float64(0), "clients_outdated": float64(0)},
map[string]interface{}{"date": dailyStats[7], "total_requests": float64(10), "unique_clients": float64(1), "clients_using_latest_version": float64(0), "clients_outdated": float64(1)},
},
},
},
}
for _, s := range scenarios {
t.Run(s.Name, func(t *testing.T) {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/telemetry?"+s.QueryParams, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
router.ServeHTTP(w, req)
assert.Equal(t, s.ExpectedCode, w.Code)
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatalf("failed to parse response JSON: %v\nBody: %s", err, w.Body.String())
}
assert.Equal(t, s.ExpectedJSON, actual)
})
}
t.Run("Deduplicates repeated client IDs across week and month ranges", func(t *testing.T) {
if !viper.GetBool("ENABLE_TELEMETRY") || redisClient == nil {
t.Skip("redis telemetry backend is not configured")
}
ctx := context.Background()
dedupApp := fmt.Sprintf("testapp-dedup-%d", time.Now().UTC().UnixNano())
dedupClientID := "dedup-client-001"
dedupVersion := "9.9.9"
dateA := startDate.AddDate(0, 0, 1).Format("2006-01-02")
dateB := startDate.AddDate(0, 0, 2).Format("2006-01-02")
baseKey := fmt.Sprintf("stats:admin:%s", dedupApp)
seededKeys := []string{
fmt.Sprintf("%s:requests:%s", baseKey, dateA),
fmt.Sprintf("%s:requests:%s", baseKey, dateB),
fmt.Sprintf("%s:unique_clients:%s", baseKey, dateA),
fmt.Sprintf("%s:unique_clients:%s", baseKey, dateB),
fmt.Sprintf("%s:clients_using_latest_version:%s", baseKey, dateA),
fmt.Sprintf("%s:clients_using_latest_version:%s", baseKey, dateB),
fmt.Sprintf("%s:clients_outdated:%s", baseKey, dateA),
fmt.Sprintf("%s:clients_outdated:%s", baseKey, dateB),
fmt.Sprintf("%s:version_usage:%s:%s", baseKey, dateA, dedupVersion),
fmt.Sprintf("%s:version_usage:%s:%s", baseKey, dateB, dedupVersion),
fmt.Sprintf("%s:known_versions", baseKey),
}
t.Cleanup(func() {
if len(seededKeys) > 0 {
_ = redisClient.Del(ctx, seededKeys...).Err()
}
})
assert.NoError(t, redisClient.Set(ctx, seededKeys[0], 3, 0).Err())
assert.NoError(t, redisClient.Set(ctx, seededKeys[1], 2, 0).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[2], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[3], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[4], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[5], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[6], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[7], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[8], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[9], dedupClientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, seededKeys[10], dedupVersion).Err())
queryScenarios := []string{
fmt.Sprintf("range=week&apps=%s", dedupApp),
fmt.Sprintf("range=month&apps=%s", dedupApp),
}
for _, query := range queryScenarios {
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", "/telemetry?"+query, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var actual map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &actual)
if err != nil {
t.Fatalf("failed to parse response JSON: %v\nBody: %s", err, w.Body.String())
}
summary, ok := actual["summary"].(map[string]interface{})
if !ok {
t.Fatalf("summary is missing or invalid: %v", actual["summary"])
}
assert.Equal(t, float64(5), summary["total_requests"])
assert.Equal(t, float64(1), summary["unique_clients"])
assert.Equal(t, float64(1), summary["clients_using_latest_version"])
assert.Equal(t, float64(1), summary["clients_outdated"])
assert.Equal(t, float64(1), summary["total_active_apps"])
versions, ok := actual["versions"].(map[string]interface{})
if !ok {
t.Fatalf("versions is missing or invalid: %v", actual["versions"])
}
assert.Equal(t, float64(1), versions["used_versions_count"])
usage, ok := versions["usage"].([]interface{})
if !ok || len(usage) != 1 {
t.Fatalf("versions.usage is invalid: %v", versions["usage"])
}
usageEntry, ok := usage[0].(map[string]interface{})
if !ok {
t.Fatalf("versions.usage[0] is invalid: %v", usage[0])
}
assert.Equal(t, dedupVersion, usageEntry["version"])
assert.Equal(t, float64(1), usageEntry["client_count"])
}
})
}
func TestTelemetryLuaEmitsArchAndChannelKeys(t *testing.T) {
if !viper.GetBool("ENABLE_TELEMETRY") || redisClient == nil {
t.Skip("redis telemetry backend is not configured")
}
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/telemetry", func(c *gin.Context) {
appHandler.GetTelemetry(c)
})
ctx := context.Background()
admin := "admin"
testApp := fmt.Sprintf("telemetry-raw-keys-%d", time.Now().UTC().UnixNano())
clientID := "lua-key-client-1"
testDate := time.Now().UTC().Format("2006-01-02")
baseKey := fmt.Sprintf("stats:%s:%s", admin, testApp)
keysToCleanup := []string{
fmt.Sprintf("%s:requests:%s", baseKey, testDate),
fmt.Sprintf("%s:unique_clients:%s", baseKey, testDate),
fmt.Sprintf("%s:architectures:%s:arm64", baseKey, testDate),
fmt.Sprintf("%s:channels:%s:stable", baseKey, testDate),
}
t.Cleanup(func() {
_ = redisClient.Del(ctx, keysToCleanup...).Err()
})
assert.NoError(t, redisClient.Set(ctx, keysToCleanup[0], 1, 0).Err())
assert.NoError(t, redisClient.SAdd(ctx, keysToCleanup[1], clientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, keysToCleanup[2], clientID).Err())
assert.NoError(t, redisClient.SAdd(ctx, keysToCleanup[3], clientID).Err())
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", fmt.Sprintf("/telemetry?date=%s&apps=%s", testDate, testApp), nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+authToken)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to parse telemetry response: %v\nBody: %s", err, w.Body.String())
}
architectures, ok := result["architectures"].([]interface{})
if !ok || len(architectures) != 1 {
t.Fatalf("unexpected architectures payload: %v", result["architectures"])
}
archEntry, ok := architectures[0].(map[string]interface{})
if !ok {
t.Fatalf("unexpected architectures[0] payload: %v", architectures[0])
}
assert.Equal(t, "arm64", archEntry["arch"])
_, hasArchPlatformKey := archEntry["platform"]
assert.False(t, hasArchPlatformKey)
channels, ok := result["channels"].([]interface{})
if !ok || len(channels) != 1 {
t.Fatalf("unexpected channels payload: %v", result["channels"])
}
channelEntry, ok := channels[0].(map[string]interface{})
if !ok {
t.Fatalf("unexpected channels[0] payload: %v", channels[0])
}
assert.Equal(t, "stable", channelEntry["channel"])
_, hasChannelPlatformKey := channelEntry["platform"]
assert.False(t, hasChannelPlatformKey)
}
func TestTelemetryBeaconUsesAllowListAndExistingRedisKeys(t *testing.T) {
if !viper.GetBool("ENABLE_TELEMETRY") || redisClient == nil {
t.Skip("redis telemetry backend is not configured")
}
ctx := context.Background()
owner := fmt.Sprintf("beacon-owner-%d", time.Now().UTC().UnixNano())
appName := "beaconapp"
channel := "stable"
platform := "darwin"
arch := "arm64"
version := "1.2.3"
dateStr := time.Now().UTC().Format("2006-01-02")
baseKey := fmt.Sprintf("stats:%s:%s", owner, appName)
keysToCleanup := []string{
fmt.Sprintf("%s:requests:%s", baseKey, dateStr),
fmt.Sprintf("%s:unique_clients:%s", baseKey, dateStr),
fmt.Sprintf("%s:channels:%s:%s", baseKey, dateStr, channel),
fmt.Sprintf("%s:platforms:%s:%s", baseKey, dateStr, platform),
fmt.Sprintf("%s:architectures:%s:%s", baseKey, dateStr, arch),
fmt.Sprintf("%s:known_versions", baseKey),
fmt.Sprintf("%s:version_usage:%s:%s", baseKey, dateStr, version),
fmt.Sprintf("%s:clients_using_latest_version:%s", baseKey, dateStr),
fmt.Sprintf("%s:clients_outdated:%s", baseKey, dateStr),
}
metaCollection := mongoDatabase.Collection("apps_meta")
appsCollection := mongoDatabase.Collection("apps")
t.Cleanup(func() {
_, _ = metaCollection.DeleteMany(ctx, bson.M{"owner": owner})
_, _ = appsCollection.DeleteMany(ctx, bson.M{"owner": owner})
_ = redisClient.Del(ctx, keysToCleanup...).Err()
})
_ = redisClient.Del(ctx, keysToCleanup...).Err()
_, _ = metaCollection.DeleteMany(ctx, bson.M{"owner": owner})
_, _ = appsCollection.DeleteMany(ctx, bson.M{"owner": owner})
appID := primitive.NewObjectID()
channelID := primitive.NewObjectID()
platformID := primitive.NewObjectID()
archID := primitive.NewObjectID()
now := primitive.NewDateTimeFromTime(time.Now().UTC())
_, err := metaCollection.InsertMany(ctx, []interface{}{
bson.M{"_id": appID, "app_name": appName, "owner": owner, "updated_at": now},
bson.M{"_id": channelID, "channel_name": channel, "owner": owner, "updated_at": now},
bson.M{"_id": platformID, "platform_name": platform, "owner": owner, "updated_at": now},
bson.M{"_id": archID, "arch_id": arch, "owner": owner, "updated_at": now},
})
require.NoError(t, err)
_, err = appsCollection.InsertOne(ctx, bson.M{
"_id": primitive.NewObjectID(),
"app_id": appID,
"version": version,
"channel_id": channelID,
"published": true,
"owner": owner,
"artifacts": []bson.M{
{"platform": platformID, "arch": archID, "package": ".zip", "link": "https://example.com/beacon.zip"},
},
"updated_at": now,
})
require.NoError(t, err)
require.NoError(t, info.ReloadTelemetryAllowList(ctx, mongoDatabase))
router := gin.Default()
appHandler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/telemetry/beacon", func(c *gin.Context) {
appHandler.TelemetryBeacon(c)
})
validQuery := fmt.Sprintf(
"/telemetry/beacon?owner=%s&app_name=%s&version=%s&channel=%s&platform=%s&arch=%s",
url.QueryEscape(owner),
url.QueryEscape(appName),
url.QueryEscape(version),
url.QueryEscape(channel),
url.QueryEscape(platform),
url.QueryEscape(arch),
)
w := httptest.NewRecorder()
req, err := http.NewRequest("GET", validQuery+"&is_latest=true", nil)
require.NoError(t, err)
req.Header.Set("X-Device-ID", "beacon-device-latest")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
requestCount, err := redisClient.Get(ctx, keysToCleanup[0]).Int()
require.NoError(t, err)
assert.Equal(t, 1, requestCount)
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[1]).Val())
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[2]).Val())
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[3]).Val())
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[4]).Val())
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[6]).Val())
assert.Equal(t, int64(1), redisClient.SCard(ctx, keysToCleanup[7]).Val())
assert.Equal(t, int64(0), redisClient.SCard(ctx, keysToCleanup[8]).Val())
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", validQuery+"&is_latest=false", nil)
require.NoError(t, err)
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
requestCountAfterMissingDeviceID, err := redisClient.Get(ctx, keysToCleanup[0]).Int()
require.NoError(t, err)
assert.Equal(t, 1, requestCountAfterMissingDeviceID)
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", validQuery, nil)
require.NoError(t, err)
req.Header.Set("X-Device-ID", "beacon-device-unknown-latest")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.False(t, redisClient.SIsMember(ctx, keysToCleanup[7], "beacon-device-unknown-latest").Val())
assert.False(t, redisClient.SIsMember(ctx, keysToCleanup[8], "beacon-device-unknown-latest").Val())
noVersionQuery := fmt.Sprintf(
"/telemetry/beacon?owner=%s&app_name=%s&channel=%s&platform=%s&arch=%s",
url.QueryEscape(owner),
url.QueryEscape(appName),
url.QueryEscape(channel),
url.QueryEscape(platform),
url.QueryEscape(arch),
)
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", noVersionQuery+"&is_latest=false", nil)
require.NoError(t, err)
req.Header.Set("X-Device-ID", "beacon-device-outdated-no-version")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.False(t, redisClient.SIsMember(ctx, keysToCleanup[6], "beacon-device-outdated-no-version").Val())
assert.True(t, redisClient.SIsMember(ctx, keysToCleanup[8], "beacon-device-outdated-no-version").Val())
w = httptest.NewRecorder()
req, err = http.NewRequest("GET", strings.Replace(validQuery, "arch=arm64", "arch=x64", 1)+"&is_latest=false", nil)
require.NoError(t, err)
req.Header.Set("X-Device-ID", "beacon-device-invalid")
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
requestCountAfterInvalid, err := redisClient.Get(ctx, keysToCleanup[0]).Int()
require.NoError(t, err)
assert.Equal(t, 3, requestCountAfterInvalid)
assert.False(t, redisClient.SIsMember(ctx, keysToCleanup[8], "beacon-device-invalid").Val())
}
func TestListAppsWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /app/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/app/list", func(c *gin.Context) {
handler.ListApps(c)
})
// Create a POST request for the /app/list endpoint.
req, err := http.NewRequest("GET", "/app/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"apps":null}`
assert.Equal(t, expected, w.Body.String())
}
func TestListChannelsWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /channel/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/channel/list", func(c *gin.Context) {
handler.ListChannels(c)
})
// Create a POST request for the /channel/list endpoint.
req, err := http.NewRequest("GET", "/channel/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"channels":null}`
assert.Equal(t, expected, w.Body.String())
}
func TestListPlatformsWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /platform/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/platform/list", func(c *gin.Context) {
handler.ListPlatforms(c)
})
// Create a POST request for the /platform/list endpoint.
req, err := http.NewRequest("GET", "/platform/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"platforms":null}`
assert.Equal(t, expected, w.Body.String())
}
func TestListArchsWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /arch/list endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.GET("/arch/list", func(c *gin.Context) {
handler.ListArchs(c)
})
// Create a POST request for the /arch/list endpoint.
req, err := http.NewRequest("GET", "/arch/list", nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusOK, w.Code)
expected := `{"archs":null}`
assert.Equal(t, expected, w.Body.String())
}
func TestUpdateAppWithSecondUser(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /app/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/app/update", func(c *gin.Context) {
handler.UpdateApp(c)
})
// Create multipart/form-data request body
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Add a field for the channel to the form
dataPart, err := writer.CreateFormField("data")
if err != nil {
t.Fatal(err)
}
payload := fmt.Sprintf(`{"id": "%s", "app":"newApp"}`, idTestappApp)
_, err = dataPart.Write([]byte(payload))
if err != nil {
t.Fatal(err)
}
// Close the writer to finalize the form data
err = writer.Close()
if err != nil {
t.Fatal(err)
}
// Create a POST request to the /app/update endpoint
req, err := http.NewRequest("POST", "/app/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Set the Content-Type header for multipart/form-data
req.Header.Set("Content-Type", writer.FormDataContentType())
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status %d; got %d", http.StatusNotFound, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"app_name not found in apps_meta collection"}`
assert.Equal(t, expected, w.Body.String())
}
func TestUpdateChannelWithSecondUser(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /channel/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/channel/update", func(c *gin.Context) {
handler.UpdateChannel(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"channel": "unstable"
}`, idStableChannel)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /channel/update endpoint
req, err := http.NewRequest("POST", "/channel/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 500 Internal Server Error)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d; got %d", http.StatusInternalServerError, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"you don't have permission to update this channel"}`
assert.Equal(t, expected, w.Body.String())
}
func TestUpdatePlatformWithSecondUser(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /platform/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/platform/update", func(c *gin.Context) {
handler.UpdatePlatform(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"platform": "newPlatform",
"updaters": [
{ "type": "manual", "default": true }
]
}`, platformId)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /platform/update endpoint
req, err := http.NewRequest("POST", "/platform/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d; got %d", http.StatusInternalServerError, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"you don't have permission to update this platform"}`
assert.Equal(t, expected, w.Body.String())
}
func TestUpdateArchWithSecondUser(t *testing.T) {
// Initialize Gin router and recorder for the test
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the handler for the /arch/update route
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.POST("/arch/update", func(c *gin.Context) {
handler.UpdateArch(c)
})
payload := fmt.Sprintf(`{
"id": "%s",
"arch": "newArch"
}`, archId)
body := bytes.NewReader([]byte(payload))
// Create a POST request to the /arch/update endpoint
req, err := http.NewRequest("POST", "/arch/update", body)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Set the Content-Type header for JSON
req.Header.Set("Content-Type", "application/json")
// Serve the request using the Gin router
router.ServeHTTP(w, req)
logrus.Infoln("Response Body:", w.Body.String())
// Check the response status code (expecting 200 OK)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status %d; got %d", http.StatusInternalServerError, w.Code)
}
// Parse the JSON response
var response map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatal(err)
}
expected := `{"error":"you don't have permission to update this arch"}`
assert.Equal(t, expected, w.Body.String())
}
func TestMultipleDeleteWithSameExtensionArtifactsAndDiffPlatformsArchsWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
// Define the route for the /apps/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/apps/delete", func(c *gin.Context) {
handler.DeleteSpecificVersionOfApp(c)
})
// Iterate over the uploadedAppIDsWithSameExtension and send a DELETE request for each ID.
for _, appID := range uploadedAppIDsWithSameExtension {
w := httptest.NewRecorder()
req, err := http.NewRequest("DELETE", "/apps/delete?id="+appID, nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code for each request.
assert.Equal(t, http.StatusInternalServerError, w.Code)
expected := `{"details":"you don't have permission to delete this item","error":"failed to delete specific version of app"}`
assert.Equal(t, expected, w.Body.String())
}
}
func TestDeleteNightlyChannelWithSecondUser(t *testing.T) {
router := gin.Default()
router.Use(utils.AuthMiddleware(mongoDatabase))
w := httptest.NewRecorder()
// Define the route for the /channel/delete endpoint.
handler := handler.NewAppHandler(client, appDB, mongoDatabase, redisClient, viper.GetBool("PERFORMANCE_MODE"))
router.DELETE("/channel/delete", func(c *gin.Context) {
handler.DeleteChannel(c)
})
// Create a DELETE request for the /channel/delete endpoint.
req, err := http.NewRequest("DELETE", "/channel/delete?id="+idNightlyChannel, nil)
if err != nil {
t.Fatal(err)
}
// Set the Authorization header.
req.Header.Set("Authorization", "Bearer "+authTokenSecondUser)
// Serve the request using the Gin router.
router.ServeHTTP(w, req)
// Check the response status code.
assert.Equal(t, http.StatusInternalServerError, w.Code)
expected := `{"details":"you don't have permission to delete this channel","error":"failed to delete channel"}`
assert.Equal(t, expected, w.Body.String())
}
func TestDeletePlatformWithSecondUser(t *