upaas/internal/handlers/handlers_test.go
sneak dc6500eac6 Fix repository cloning when running inside a container
Use DataDir/builds instead of /tmp for clone directories so that bind
mounts work correctly when upaas itself runs in a Docker container.
The /tmp directory inside the upaas container isn't accessible to the
Docker daemon on the host, causing bind mount failures.

Also fix test setups to pass Config to deploy service and add delay
to webhook test to avoid temp directory cleanup race with async
deployment goroutine.
2025-12-29 17:02:01 +07:00

495 lines
12 KiB
Go

package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"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/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),
)
}
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)
}