package handlers_test import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "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/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) { 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, 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 } func setupTestHandlers(t *testing.T) *testContext { t.Helper() cfg := createTestConfig(t) globalInstance, logInstance, dbInstance, hcInstance := createCoreServices(t, cfg) authSvc, appSvc, deploySvc, webhookSvc := 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, }, ) 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), ) } 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) }