Initial commit with server startup infrastructure
Core infrastructure: - Uber fx dependency injection - Chi router with middleware stack - SQLite database with embedded migrations - Embedded templates and static assets - Structured logging with slog Features implemented: - Authentication (login, logout, session management, argon2id hashing) - App management (create, edit, delete, list) - Deployment pipeline (clone, build, deploy, health check) - Webhook processing for Gitea - Notifications (ntfy, Slack) - Environment variables, labels, volumes per app - SSH key generation for deploy keys Server startup: - Server.Run() starts HTTP server on configured port - Server.Shutdown() for graceful shutdown - SetupRoutes() wires all handlers with chi router
This commit is contained in:
486
internal/handlers/handlers_test.go
Normal file
486
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user