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" "git.eeqj.de/sneak/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/webhook" ) type testContext struct { handlers *handlers.Handlers database *database.Database authSvc *auth.Service appSvc *app.Service } 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) return &testContext{ handlers: handlersInstance, database: dbInstance, authSvc: authSvc, appSvc: appSvc, } } 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") }) } 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 } // TestDeleteEnvVarOwnershipVerification tests that deleting an env var // via another app's URL path returns 404 (IDOR prevention). 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) } // TestDeleteEnvVarOwnershipVerification tests that deleting an env var // via another app's URL path returns 404 (IDOR prevention). func TestDeleteEnvVarOwnershipVerification(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) app1 := createTestApp(t, testCtx, "envvar-owner-app") app2 := createTestApp(t, testCtx, "envvar-other-app") // Create env var belonging to app1 envVar := models.NewEnvVar(testCtx.database) envVar.AppID = app1.ID envVar.Key = "SECRET" envVar.Value = "hunter2" require.NoError(t, envVar.Save(context.Background())) // Try to delete app1's env var using app2's URL path request := httptest.NewRequest( http.MethodPost, "/apps/"+app2.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete", nil, ) request = addChiURLParams(request, map[string]string{ "id": app2.ID, "envID": strconv.FormatInt(envVar.ID, 10), }) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleEnvVarDelete() handler.ServeHTTP(recorder, request) // Should return 404 because the env var doesn't belong to app2 assert.Equal(t, http.StatusNotFound, recorder.Code) // Verify the env var was NOT deleted found, err := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID) require.NoError(t, err) assert.NotNil(t, found, "env var should still exist after IDOR attempt") } // TestDeleteLabelOwnershipVerification tests that deleting a label // via another app's URL path returns 404 (IDOR prevention). func TestDeleteLabelOwnershipVerification(t *testing.T) { t.Parallel() testCtx := setupTestHandlers(t) app1 := createTestApp(t, testCtx, "label-owner-app") app2 := createTestApp(t, testCtx, "label-other-app") // Create label belonging to app1 label := models.NewLabel(testCtx.database) label.AppID = app1.ID label.Key = "traefik.enable" label.Value = "true" require.NoError(t, label.Save(context.Background())) // Try to delete app1's label using app2's URL path request := httptest.NewRequest( http.MethodPost, "/apps/"+app2.ID+"/labels/"+strconv.FormatInt(label.ID, 10)+"/delete", nil, ) request = addChiURLParams(request, map[string]string{ "id": app2.ID, "labelID": strconv.FormatInt(label.ID, 10), }) recorder := httptest.NewRecorder() handler := testCtx.handlers.HandleLabelDelete() handler.ServeHTTP(recorder, request) assert.Equal(t, http.StatusNotFound, recorder.Code) // Verify the label was NOT deleted found, err := models.FindLabel(context.Background(), testCtx.database, label.ID) require.NoError(t, err) 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") } 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) }