package handlers_test import ( "context" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/upaas/internal/models" "sneak.berlin/go/upaas/internal/config" "sneak.berlin/go/upaas/internal/database" "sneak.berlin/go/upaas/internal/docker" "sneak.berlin/go/upaas/internal/globals" "sneak.berlin/go/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/logger" "sneak.berlin/go/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/webhook" ) type testContext struct { handlers *handlers.Handlers database *database.Database authSvc *auth.Service appSvc *app.Service middleware *middleware.Middleware } func createTestConfig(t *testing.T) *config.Config { t.Helper() return &config.Config{ Port: 8080, DataDir: t.TempDir(), SessionSecret: "test-secret-key-at-least-32-characters-long", } } func createCoreServices( t *testing.T, cfg *config.Config, ) (*globals.Globals, *logger.Logger, *database.Database, *healthcheck.Healthcheck) { t.Helper() globals.SetAppname("upaas-test") globals.SetVersion("test") globalInstance, globErr := globals.New(fx.Lifecycle(nil)) require.NoError(t, globErr) logInstance, logErr := logger.New( fx.Lifecycle(nil), logger.Params{Globals: globalInstance}, ) require.NoError(t, logErr) dbInstance, dbErr := database.New(fx.Lifecycle(nil), database.Params{ Logger: logInstance, Config: cfg, }) require.NoError(t, dbErr) hcInstance, hcErr := healthcheck.New( fx.Lifecycle(nil), healthcheck.Params{ Logger: logInstance, Globals: globalInstance, Config: cfg, }, ) require.NoError(t, hcErr) return globalInstance, logInstance, dbInstance, hcInstance } func createAppServices( t *testing.T, logInstance *logger.Logger, dbInstance *database.Database, cfg *config.Config, ) (*auth.Service, *app.Service, *deploy.Service, *webhook.Service, *docker.Client) { t.Helper() authSvc, authErr := auth.New(fx.Lifecycle(nil), auth.ServiceParams{ Logger: logInstance, Config: cfg, Database: dbInstance, }) require.NoError(t, authErr) appSvc, appErr := app.New(fx.Lifecycle(nil), app.ServiceParams{ Logger: logInstance, Database: dbInstance, }) require.NoError(t, appErr) dockerClient, dockerErr := docker.New(fx.Lifecycle(nil), docker.Params{ Logger: logInstance, Config: cfg, }) require.NoError(t, dockerErr) notifySvc, notifyErr := notify.New(fx.Lifecycle(nil), notify.ServiceParams{ Logger: logInstance, }) require.NoError(t, notifyErr) deploySvc, deployErr := deploy.New(fx.Lifecycle(nil), deploy.ServiceParams{ Logger: logInstance, Config: cfg, Database: dbInstance, Docker: dockerClient, Notify: notifySvc, }) require.NoError(t, deployErr) webhookSvc, webhookErr := webhook.New(fx.Lifecycle(nil), webhook.ServiceParams{ Logger: logInstance, Database: dbInstance, Deploy: deploySvc, }) require.NoError(t, webhookErr) return authSvc, appSvc, deploySvc, webhookSvc, dockerClient } func setupTestHandlers(t *testing.T) *testContext { t.Helper() cfg := createTestConfig(t) globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg) authSvc, appSvc, deploySvc, webhookSvc, dockerClient := createAppServices( t, logInstance, dbInstance, cfg, ) handlersInstance, handlerErr := handlers.New( fx.Lifecycle(nil), handlers.Params{ Logger: logInstance, Globals: globalInstance, Database: dbInstance, Healthcheck: hcInstance, Auth: authSvc, App: appSvc, Deploy: deploySvc, Webhook: webhookSvc, Docker: dockerClient, }, ) require.NoError(t, handlerErr) mw, mwErr := middleware.New(fx.Lifecycle(nil), middleware.Params{ Logger: logInstance, Globals: globalInstance, Config: cfg, Auth: authSvc, }) require.NoError(t, mwErr) return &testContext{ handlers: handlersInstance, database: dbInstance, authSvc: authSvc, appSvc: appSvc, middleware: mw, } } func TestHandleHealthCheck(t *testing.T) { t.Parallel() t.Run("returns health check response", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest( http.MethodGet, "/.well-known/healthcheck.json", nil, ) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleHealthCheck() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json") assert.Contains(t, recorder.Body.String(), "status") assert.Contains(t, recorder.Body.String(), "ok") }) } func TestHandleSetupGET(t *testing.T) { t.Parallel() t.Run("renders setup page", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest(http.MethodGet, "/setup", nil) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleSetupGET() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "setup") }) } func createSetupFormRequest( username, password, confirm string, ) *http.Request { form := url.Values{} form.Set("username", username) form.Set("password", password) form.Set("password_confirm", confirm) request := httptest.NewRequest( http.MethodPost, "/setup", strings.NewReader(form.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") return request } func TestHandleSetupPOSTCreatesUserAndRedirects(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := createSetupFormRequest("admin", "password123", "password123") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleSetupPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusSeeOther, recorder.Code) assert.Equal(t, "/", recorder.Header().Get("Location")) } func TestHandleSetupPOSTRejectsEmptyUsername(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := createSetupFormRequest("", "password123", "password123") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleSetupPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "required") } func TestHandleSetupPOSTRejectsShortPassword(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := createSetupFormRequest("admin", "short", "short") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleSetupPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "8 characters") } func TestHandleSetupPOSTRejectsMismatchedPasswords(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := createSetupFormRequest("admin", "password123", "different123") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleSetupPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "do not match") } func TestHandleLoginGET(t *testing.T) { t.Parallel() t.Run("renders login page", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest(http.MethodGet, "/login", nil) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleLoginGET() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "login") }) } func createLoginFormRequest(username, password string) *http.Request { form := url.Values{} form.Set("username", username) form.Set("password", password) request := httptest.NewRequest( http.MethodPost, "/login", strings.NewReader(form.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") return request } func TestHandleLoginPOSTAuthenticatesValidCredentials(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // Create user first _, createErr := testCtx.authSvc.CreateUser( context.Background(), "testuser", "testpass123", ) require.NoError(t, createErr) request := createLoginFormRequest("testuser", "testpass123") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleLoginPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusSeeOther, recorder.Code) assert.Equal(t, "/", recorder.Header().Get("Location")) } func TestHandleLoginPOSTRejectsInvalidCredentials(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // Create user first _, createErr := testCtx.authSvc.CreateUser( context.Background(), "testuser", "testpass123", ) require.NoError(t, createErr) request := createLoginFormRequest("testuser", "wrongpassword") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleLoginPOST() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "Invalid") } func TestHandleDashboard(t *testing.T) { t.Parallel() t.Run("renders dashboard with app list", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest(http.MethodGet, "/", nil) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleDashboard() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) assert.Contains(t, recorder.Body.String(), "Applications") }) t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // Create an app so the template iterates over AppStats and hits .CSRFField createTestApp(t, testCtx, "csrf-test-app") request := httptest.NewRequest(http.MethodGet, "/", nil) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleDashboard() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code, "dashboard should not 500 when apps exist (CSRFField must be accessible)") assert.Contains(t, recorder.Body.String(), "csrf-test-app") }) } func TestHandleAppNew(t *testing.T) { t.Parallel() t.Run("renders new app form", func(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest(http.MethodGet, "/apps/new", nil) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleAppNew() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) }) } // addChiURLParams adds chi URL parameters to a request for testing. func addChiURLParams( request *http.Request, params map[string]string, ) *http.Request { routeContext := chi.NewRouteContext() for key, value := range params { routeContext.URLParams.Add(key, value) } return request.WithContext( context.WithValue(request.Context(), chi.RouteCtxKey, routeContext), ) } // createTestApp creates an app using the app service and returns it. func createTestApp( t *testing.T, tc *testContext, name string, ) *models.App { t.Helper() createdApp, err := tc.appSvc.CreateApp( context.Background(), app.CreateAppInput{ Name: name, RepoURL: "git@example.com:user/" + name + ".git", Branch: "main", }, ) require.NoError(t, err) return createdApp } // TestHandleWebhookRejectsOversizedBody tests that oversized webhook payloads // are handled gracefully. func TestHandleWebhookRejectsOversizedBody(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // Create an app first createdApp, createErr := testCtx.appSvc.CreateApp( context.Background(), app.CreateAppInput{ Name: "oversize-test-app", RepoURL: "git@example.com:user/repo.git", Branch: "main", }, ) require.NoError(t, createErr) // Create a body larger than 1MB - it should be silently truncated // and the webhook should still process (or fail gracefully on parse) largePayload := strings.Repeat("x", 2*1024*1024) // 2MB request := httptest.NewRequest( http.MethodPost, "/webhook/"+createdApp.WebhookSecret, strings.NewReader(largePayload), ) request = addChiURLParams( request, map[string]string{"secret": createdApp.WebhookSecret}, ) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Gitea-Event", "push") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleWebhook() handler.ServeHTTP(recorder, request) // Should still return OK (payload is truncated and fails JSON parse, // but webhook service handles invalid JSON gracefully) assert.Equal(t, http.StatusOK, recorder.Code) } // ownedResourceTestConfig configures an IDOR ownership verification test. type ownedResourceTestConfig struct { appPrefix1 string appPrefix2 string createFn func(t *testing.T, tc *testContext, app *models.App) int64 deletePath func(appID string, resourceID int64) string chiParams func(appID string, resourceID int64) map[string]string handler func(h *handlers.Handlers) http.HandlerFunc verifyFn func(t *testing.T, tc *testContext, resourceID int64) } func testOwnershipVerification(t *testing.T, cfg ownedResourceTestConfig) { t.Helper() testCtx := setupTestHandlers(t) app1 := createTestApp(t, testCtx, cfg.appPrefix1) app2 := createTestApp(t, testCtx, cfg.appPrefix2) resourceID := cfg.createFn(t, testCtx, app1) request := httptest.NewRequest( http.MethodPost, cfg.deletePath(app2.ID, resourceID), nil, ) request = addChiURLParams(request, cfg.chiParams(app2.ID, resourceID)) recorder := httptest.NewRecorder() handler := cfg.handler(testCtx.handlers) handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) cfg.verifyFn(t, testCtx, resourceID) } // TestHandleEnvVarSaveBulk tests that HandleEnvVarSave replaces all env vars // for an app with the submitted set (monolithic delete-all + insert-all). func TestHandleEnvVarSaveBulk(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "envvar-bulk-app") // Create some pre-existing env vars for _, kv := range [][2]string{{"OLD_KEY", "old_value"}, {"REMOVE_ME", "gone"}} { ev := models.NewEnvVar(testCtx.database) ev.AppID = createdApp.ID ev.Key = kv[0] ev.Value = kv[1] require.NoError(t, ev.Save(context.Background())) } // Submit a new set as a JSON array of key/value objects body := `[{"key":"NEW_KEY","value":"new_value"},{"key":"ANOTHER","value":"42"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) // Verify old env vars are gone and new ones exist envVars, err := models.FindEnvVarsByAppID( context.Background(), testCtx.database, createdApp.ID, ) require.NoError(t, err) assert.Len(t, envVars, 2) keys := make(map[string]string) for _, ev := range envVars { keys[ev.Key] = ev.Value } assert.Equal(t, "new_value", keys["NEW_KEY"]) assert.Equal(t, "42", keys["ANOTHER"]) assert.Empty(t, keys["OLD_KEY"], "old env vars should be deleted") assert.Empty(t, keys["REMOVE_ME"], "old env vars should be deleted") } // TestHandleEnvVarSaveAppNotFound tests that HandleEnvVarSave returns 404 // for a non-existent app. func TestHandleEnvVarSaveAppNotFound(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) body := `[{"key":"KEY","value":"value"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/nonexistent-id/env", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) } // TestHandleEnvVarSaveEmptyKeyRejected verifies that submitting a JSON // array containing an entry with an empty key returns 400. func TestHandleEnvVarSaveEmptyKeyRejected(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "envvar-emptykey-app") body := `[{"key":"VALID_KEY","value":"ok"},{"key":"","value":"bad"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusBadRequest, recorder.Code) } // TestHandleEnvVarSaveDuplicateKeyDedup verifies that when the client // sends duplicate keys, the server deduplicates them (last wins). func TestHandleEnvVarSaveDuplicateKeyDedup(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "envvar-dedup-app") // Send two entries with the same key — last should win body := `[{"key":"FOO","value":"first"},{"key":"BAR","value":"bar"},{"key":"FOO","value":"second"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) envVars, err := models.FindEnvVarsByAppID( context.Background(), testCtx.database, createdApp.ID, ) require.NoError(t, err) assert.Len(t, envVars, 2, "duplicate key should be deduplicated") keys := make(map[string]string) for _, ev := range envVars { keys[ev.Key] = ev.Value } assert.Equal(t, "second", keys["FOO"], "last occurrence should win") assert.Equal(t, "bar", keys["BAR"]) } // TestHandleEnvVarSaveCrossAppIsolation verifies that posting env vars // to appA's endpoint does not affect appB's env vars (IDOR prevention). func TestHandleEnvVarSaveCrossAppIsolation(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) appA := createTestApp(t, testCtx, "envvar-iso-appA") appB := createTestApp(t, testCtx, "envvar-iso-appB") // Give appB some env vars for _, kv := range [][2]string{{"B_KEY1", "b_val1"}, {"B_KEY2", "b_val2"}} { ev := models.NewEnvVar(testCtx.database) ev.AppID = appB.ID ev.Key = kv[0] ev.Value = kv[1] require.NoError(t, ev.Save(context.Background())) } // POST new env vars to appA's endpoint body := `[{"key":"A_KEY","value":"a_val"}]` r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+appA.ID+"/env", strings.NewReader(body), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) // Verify appA has exactly what we sent appAVars, err := models.FindEnvVarsByAppID( context.Background(), testCtx.database, appA.ID, ) require.NoError(t, err) assert.Len(t, appAVars, 1) assert.Equal(t, "A_KEY", appAVars[0].Key) // Verify appB's env vars are completely untouched appBVars, err := models.FindEnvVarsByAppID( context.Background(), testCtx.database, appB.ID, ) require.NoError(t, err) assert.Len(t, appBVars, 2, "appB env vars must not be affected") bKeys := make(map[string]string) for _, ev := range appBVars { bKeys[ev.Key] = ev.Value } assert.Equal(t, "b_val1", bKeys["B_KEY1"]) assert.Equal(t, "b_val2", bKeys["B_KEY2"]) } // TestHandleEnvVarSaveBodySizeLimit verifies that a request body // exceeding the 1 MB limit is rejected. func TestHandleEnvVarSaveBodySizeLimit(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "envvar-sizelimit-app") // Build a JSON body that exceeds 1 MB // Each entry is ~30 bytes; 40000 entries ≈ 1.2 MB var sb strings.Builder sb.WriteString("[") for i := range 40000 { if i > 0 { sb.WriteString(",") } sb.WriteString(`{"key":"K` + strconv.Itoa(i) + `","value":"val"}`) } sb.WriteString("]") r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", strings.NewReader(sb.String()), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusBadRequest, recorder.Code, "oversized body should be rejected with 400") } // TestDeleteLabelOwnershipVerification tests that deleting a label // via another app's URL path returns 404 (IDOR prevention). func TestDeleteLabelOwnershipVerification(t *testing.T) { t.Parallel() testOwnershipVerification(t, ownedResourceTestConfig{ appPrefix1: "label-owner-app", appPrefix2: "label-other-app", createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 { t.Helper() lbl := models.NewLabel(tc.database) lbl.AppID = ownerApp.ID lbl.Key = "traefik.enable" lbl.Value = "true" require.NoError(t, lbl.Save(context.Background())) return lbl.ID }, deletePath: func(appID string, resourceID int64) string { return "/apps/" + appID + "/labels/" + strconv.FormatInt(resourceID, 10) + "/delete" }, chiParams: func(appID string, resourceID int64) map[string]string { return map[string]string{"id": appID, "labelID": strconv.FormatInt(resourceID, 10)} }, handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleLabelDelete() }, verifyFn: func(t *testing.T, tc *testContext, resourceID int64) { t.Helper() found, findErr := models.FindLabel(context.Background(), tc.database, resourceID) require.NoError(t, findErr) assert.NotNil(t, found, "label should still exist after IDOR attempt") }, }) } // TestDeleteVolumeOwnershipVerification tests that deleting a volume // via another app's URL path returns 404 (IDOR prevention). func TestDeleteVolumeOwnershipVerification(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) app1 := createTestApp(t, testCtx, "volume-owner-app") app2 := createTestApp(t, testCtx, "volume-other-app") // Create volume belonging to app1 volume := models.NewVolume(testCtx.database) volume.AppID = app1.ID volume.HostPath = "/data/app1" volume.ContainerPath = "/app/data" volume.ReadOnly = false require.NoError(t, volume.Save(context.Background())) // Try to delete app1's volume using app2's URL path request := httptest.NewRequest( http.MethodPost, "/apps/"+app2.ID+"/volumes/"+strconv.FormatInt(volume.ID, 10)+"/delete", nil, ) request = addChiURLParams(request, map[string]string{ "id": app2.ID, "volumeID": strconv.FormatInt(volume.ID, 10), }) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleVolumeDelete() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) // Verify the volume was NOT deleted found, err := models.FindVolume(context.Background(), testCtx.database, volume.ID) require.NoError(t, err) assert.NotNil(t, found, "volume should still exist after IDOR attempt") } // TestDeletePortOwnershipVerification tests that deleting a port // via another app's URL path returns 404 (IDOR prevention). func TestDeletePortOwnershipVerification(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) app1 := createTestApp(t, testCtx, "port-owner-app") app2 := createTestApp(t, testCtx, "port-other-app") // Create port belonging to app1 port := models.NewPort(testCtx.database) port.AppID = app1.ID port.HostPort = 8080 port.ContainerPort = 80 port.Protocol = models.PortProtocolTCP require.NoError(t, port.Save(context.Background())) // Try to delete app1's port using app2's URL path request := httptest.NewRequest( http.MethodPost, "/apps/"+app2.ID+"/ports/"+strconv.FormatInt(port.ID, 10)+"/delete", nil, ) request = addChiURLParams(request, map[string]string{ "id": app2.ID, "portID": strconv.FormatInt(port.ID, 10), }) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandlePortDelete() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) // Verify the port was NOT deleted found, err := models.FindPort(context.Background(), testCtx.database, port.ID) require.NoError(t, err) assert.NotNil(t, found, "port should still exist after IDOR attempt") } // TestHandleEnvVarSaveEmptyClears verifies that submitting an empty JSON // array deletes all existing env vars for the app. func TestHandleEnvVarSaveEmptyClears(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "envvar-clear-app") // Create a pre-existing env var ev := models.NewEnvVar(testCtx.database) ev.AppID = createdApp.ID ev.Key = "DELETE_ME" ev.Value = "gone" require.NoError(t, ev.Save(context.Background())) // Submit empty JSON array r := chi.NewRouter() r.Post("/apps/{id}/env", testCtx.handlers.HandleEnvVarSave()) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/env", strings.NewReader("[]"), ) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() r.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) // Verify all env vars are gone envVars, err := models.FindEnvVarsByAppID( context.Background(), testCtx.database, createdApp.ID, ) require.NoError(t, err) assert.Empty(t, envVars, "all env vars should be deleted") } // TestHandleVolumeAddValidatesPaths verifies that HandleVolumeAdd validates // host and container paths (same as HandleVolumeEdit). func TestHandleVolumeAddValidatesPaths(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "volume-validate-app") tests := []struct { name string hostPath string containerPath string shouldCreate bool }{ {"relative host path rejected", "relative/path", "/container", false}, {"relative container path rejected", "/host", "relative/path", false}, {"unclean host path rejected", "/host/../etc", "/container", false}, {"valid paths accepted", "/host/data", "/container/data", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() form := url.Values{} form.Set("host_path", tt.hostPath) form.Set("container_path", tt.containerPath) request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/volumes", strings.NewReader(form.Encode()), ) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleVolumeAdd() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusSeeOther, recorder.Code) // Check if volume was created by listing volumes volumes, _ := createdApp.GetVolumes(context.Background()) found := false for _, v := range volumes { if v.HostPath == tt.hostPath && v.ContainerPath == tt.containerPath { found = true // Clean up for isolation _ = v.Delete(context.Background()) } } if tt.shouldCreate { assert.True(t, found, "volume should be created for valid paths") } else { assert.False(t, found, "volume should NOT be created for invalid paths") } }) } } // TestSetupRequiredExemptsHealthAndStaticAndAPI verifies that the SetupRequired // middleware allows /health, /s/*, and /api/* paths through even when setup is required. func TestSetupRequiredExemptsHealthAndStaticAndAPI(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // No user created, so setup IS required mw := testCtx.middleware.SetupRequired() okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) }) wrapped := mw(okHandler) exemptPaths := []string{"/health", "/s/style.css", "/s/js/app.js", "/api/v1/apps", "/api/v1/login"} for _, path := range exemptPaths { t.Run(path, func(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, path, nil) rr := httptest.NewRecorder() wrapped.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code, "path %s should be exempt from setup redirect", path) }) } // Non-exempt path should redirect to /setup t.Run("non-exempt redirects", func(t *testing.T) { t.Parallel() req := httptest.NewRequest(http.MethodGet, "/", nil) rr := httptest.NewRecorder() wrapped.ServeHTTP(rr, req) assert.Equal(t, http.StatusSeeOther, rr.Code) assert.Equal(t, "/setup", rr.Header().Get("Location")) }) } func TestHandleCancelDeployRedirects(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) createdApp := createTestApp(t, testCtx, "cancel-deploy-app") request := httptest.NewRequest( http.MethodPost, "/apps/"+createdApp.ID+"/deployments/cancel", nil, ) request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleCancelDeploy() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusSeeOther, recorder.Code) assert.Equal(t, "/apps/"+createdApp.ID, recorder.Header().Get("Location")) } func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) request := httptest.NewRequest( http.MethodPost, "/apps/nonexistent/deployments/cancel", nil, ) request = addChiURLParams(request, map[string]string{"id": "nonexistent"}) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleCancelDeploy() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) } func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) webhookURL := "/webhook/unknown-secret" payload := `{"ref": "refs/heads/main"}` request := httptest.NewRequest( http.MethodPost, webhookURL, strings.NewReader(payload), ) request = addChiURLParams(request, map[string]string{"secret": "unknown-secret"}) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Gitea-Event", "push") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleWebhook() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) } func TestHandleWebhookProcessesValidWebhook(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) // Create an app first createdApp, createErr := testCtx.appSvc.CreateApp( context.Background(), app.CreateAppInput{ Name: "webhook-test-app", RepoURL: "git@example.com:user/repo.git", Branch: "main", }, ) require.NoError(t, createErr) payload := `{"ref": "refs/heads/main", "after": "abc123"}` webhookURL := "/webhook/" + createdApp.WebhookSecret request := httptest.NewRequest( http.MethodPost, webhookURL, strings.NewReader(payload), ) request = addChiURLParams( request, map[string]string{"secret": createdApp.WebhookSecret}, ) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Gitea-Event", "push") recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleWebhook() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusOK, recorder.Code) // Allow async deployment goroutine to complete before test cleanup. // The deployment will fail quickly (docker not connected) but we need // to wait for it to finish to avoid temp directory cleanup race. time.Sleep(100 * time.Millisecond) }