upaas/internal/handlers/handlers_test.go
clawbot 327d7fb982
All checks were successful
Check / check (pull_request) Successful in 11m26s
fix: resolve lint issues in handlers and middleware
2026-02-20 03:35:44 -08:00

980 lines
27 KiB
Go

package handlers_test
import (
"context"
"encoding/json"
"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/middleware"
"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
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")
})
}
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)
}
// TestDeleteEnvVarOwnershipVerification tests that deleting an env var
// via another app's URL path returns 404 (IDOR prevention).
func TestDeleteEnvVarOwnershipVerification(t *testing.T) { //nolint:dupl // intentionally similar IDOR test pattern
t.Parallel()
testOwnershipVerification(t, ownedResourceTestConfig{
appPrefix1: "envvar-owner-app",
appPrefix2: "envvar-other-app",
createFn: func(t *testing.T, tc *testContext, ownerApp *models.App) int64 {
t.Helper()
envVar := models.NewEnvVar(tc.database)
envVar.AppID = ownerApp.ID
envVar.Key = "SECRET"
envVar.Value = "hunter2"
require.NoError(t, envVar.Save(context.Background()))
return envVar.ID
},
deletePath: func(appID string, resourceID int64) string {
return "/apps/" + appID + "/env/" + strconv.FormatInt(resourceID, 10) + "/delete"
},
chiParams: func(appID string, resourceID int64) map[string]string {
return map[string]string{"id": appID, "varID": strconv.FormatInt(resourceID, 10)}
},
handler: func(h *handlers.Handlers) http.HandlerFunc { return h.HandleEnvVarDelete() },
verifyFn: func(t *testing.T, tc *testContext, resourceID int64) {
t.Helper()
found, findErr := models.FindEnvVar(context.Background(), tc.database, resourceID)
require.NoError(t, findErr)
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) { //nolint:dupl // intentionally similar IDOR test pattern
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")
}
// TestHandleEnvVarDeleteUsesCorrectRouteParam verifies that HandleEnvVarDelete
// reads the "varID" chi URL parameter (matching the route definition {varID}),
// not a mismatched name like "envID".
func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
createdApp := createTestApp(t, testCtx, "envdelete-param-app")
envVar := models.NewEnvVar(testCtx.database)
envVar.AppID = createdApp.ID
envVar.Key = "DELETE_ME"
envVar.Value = "gone"
require.NoError(t, envVar.Save(context.Background()))
// Use chi router with the real route pattern to test param name
r := chi.NewRouter()
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusSeeOther, recorder.Code)
// Verify the env var was actually deleted
found, findErr := models.FindEnvVar(context.Background(), testCtx.database, envVar.ID)
require.NoError(t, findErr)
assert.Nil(t, found, "env var should be deleted when using correct route param")
}
// 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"))
})
}
// TestAPITriggerDeployUsesDetachedContext verifies that HandleAPITriggerDeploy
// does not pass the request context directly to the deploy operation.
// This is a compile-time/code-level fix verified by the deployment not being
// cancelled when the request context is cancelled.
func TestAPITriggerDeployUsesDetachedContext(t *testing.T) {
t.Parallel()
// This test verifies the fix exists by checking the handler doesn't
// fail when called — the actual context detachment is verified by code review.
// The deploy will fail (no docker) but shouldn't panic.
tc, cookies := setupAPITest(t)
body := `{"name":"detach-ctx-app","repoUrl":"https://github.com/example/repo"}`
rr := apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps", body)
require.Equal(t, http.StatusCreated, rr.Code)
var created map[string]any
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created))
appID, ok := created["id"].(string)
require.True(t, ok)
rr = apiRequest(t, tc, cookies, http.MethodPost, "/api/v1/apps/"+appID+"/deploy", "")
// Should get conflict (deploy will fail) or accepted, but not panic
assert.Contains(t, []int{http.StatusAccepted, http.StatusConflict}, rr.Code)
}
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)
}