package httpserver_test import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "runtime" "strings" "testing" "time" "update_server/internal/apikeys" "update_server/internal/auth" "update_server/internal/config" "update_server/internal/db" httpserver "update_server/internal/http" "update_server/internal/releases" "update_server/internal/storage" ) const testCSRFCookieName = "update_server_csrf" func TestAdminLoginLogoutFlowProtectsRoutes(t *testing.T) { t.Parallel() router, cfg := newTestRouter(t) recorder := performRequest(t, router, http.MethodGet, "/admin", nil) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected redirect for unauthenticated admin route, got %d", recorder.Code) } if location := recorder.Header().Get("Location"); location != "/admin/login" { t.Fatalf("expected login redirect, got %q", location) } loginForm := url.Values{ "email": {"admin@example.com"}, "password": {"correct horse battery staple"}, "next": {"/admin"}, } csrfCookie := ensureCSRFCookie(t, router) loginForm.Set("csrf_token", csrfCookie.Value) recorder = performRequest(t, router, http.MethodPost, "/admin/login", loginForm, csrfCookie) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected login redirect, got %d", recorder.Code) } if location := recorder.Header().Get("Location"); location != "/admin" { t.Fatalf("expected admin redirect after login, got %q", location) } adminCookies := recorder.Result().Cookies() var sessionCookie *http.Cookie for _, cookie := range adminCookies { if cookie.Name == cfg.SessionCookieName { sessionCookie = cookie break } } if sessionCookie == nil || sessionCookie.Value == "" { t.Fatal("expected session cookie after successful login") } recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie) if recorder.Code != http.StatusOK { t.Fatalf("expected authenticated admin dashboard, got %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Admin Dashboard") { t.Fatal("expected admin dashboard content in response body") } if !strings.Contains(recorder.Body.String(), "admin@example.com") { t.Fatal("expected authenticated admin email in dashboard response") } logoutCSRFCookie := ensureCSRFCookie(t, router, sessionCookie) recorder = performRequest(t, router, http.MethodPost, "/admin/logout", url.Values{ "csrf_token": {logoutCSRFCookie.Value}, }, sessionCookie, logoutCSRFCookie) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected logout redirect, got %d", recorder.Code) } if location := recorder.Header().Get("Location"); location != "/admin/login" { t.Fatalf("expected login redirect after logout, got %q", location) } recorder = performRequest(t, router, http.MethodGet, "/admin", nil, sessionCookie) if recorder.Code != http.StatusSeeOther { t.Fatalf("expected invalidated session cookie to be rejected, got %d", recorder.Code) } if location := recorder.Header().Get("Location"); location != "/admin/login" { t.Fatalf("expected invalidated session redirect, got %q", location) } } func TestAdminLoginRejectsInvalidCredentials(t *testing.T) { t.Parallel() router, _ := newTestRouter(t) csrfCookie := ensureCSRFCookie(t, router) recorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ "email": {"admin@example.com"}, "password": {"definitely-wrong"}, "next": {"/admin"}, "csrf_token": {csrfCookie.Value}, }, csrfCookie) if recorder.Code != http.StatusUnauthorized { t.Fatalf("expected unauthorized login response, got %d", recorder.Code) } if !strings.Contains(recorder.Body.String(), "Invalid email or password.") { t.Fatal("expected invalid login message in response body") } } func newTestRouter(t *testing.T) (http.Handler, config.Config) { t.Helper() router, cfg, _ := newTestRouterWithStore(t) return router, cfg } func newTestRouterWithStore(t *testing.T) (http.Handler, config.Config, *db.Store) { return newTestRouterWithConfig(t, nil) } func newTestRouterWithConfig(t *testing.T, mutate func(*config.Config)) (http.Handler, config.Config, *db.Store) { t.Helper() store := newHTTPTestStore(t) t.Cleanup(func() { _ = store.Close() }) artifactsDir := filepath.Join(t.TempDir(), "artifacts") cfg := config.Config{ AppName: "Update Server", BaseURL: "http://127.0.0.1:8080", ArtifactsDir: artifactsDir, TemplatesDir: httpProjectPath(t, "web", "templates"), StaticDir: httpProjectPath(t, "web", "static"), AdminEmail: "admin@example.com", AdminPassword: "correct horse battery staple", MaxUploadBytes: 8 << 20, SessionCookieName: "update_server_session", CSRFCookieName: testCSRFCookieName, SessionTTL: 24 * time.Hour, ReadTimeout: 30 * time.Second, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, ShutdownTimeout: 10 * time.Second, MaxHeaderBytes: 1 << 20, LoginRateLimitPerMinute: 10, LoginRateLimitBurst: 5, ClientRateLimitPerMinute: 120, ClientRateLimitBurst: 60, } if mutate != nil { mutate(&cfg) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) renderer, err := httpserver.NewRenderer(cfg.TemplatesDir) if err != nil { t.Fatalf("create renderer: %v", err) } authService := auth.NewService(cfg, logger, store) apiKeyService := apikeys.NewService(store) if err := authService.EnsureBootstrapAdmin(context.Background()); err != nil { t.Fatalf("bootstrap admin: %v", err) } artifactStore, err := storage.NewLocal(cfg.ArtifactsDir) if err != nil { t.Fatalf("create artifact storage: %v", err) } releaseService := releases.NewService(store, artifactStore) return httpserver.NewRouter(cfg, logger, renderer, store, authService, apiKeyService, releaseService), cfg, store } func newHTTPTestStore(t *testing.T) *db.Store { t.Helper() sqlitePath := filepath.Join(t.TempDir(), "update-server.sqlite") database, err := db.Open(context.Background(), sqlitePath) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Migrate(context.Background(), database, httpProjectPath(t, "migrations")); err != nil { _ = database.Close() t.Fatalf("migrate sqlite: %v", err) } return db.NewStore(database) } func httpProjectPath(t *testing.T, parts ...string) string { t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { t.Fatal("resolve caller path") } root := filepath.Join(filepath.Dir(filename), "..", "..") items := append([]string{root}, parts...) return filepath.Join(items...) } func performRequest(t *testing.T, handler http.Handler, method, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { t.Helper() var body io.Reader if form != nil { body = strings.NewReader(form.Encode()) } req := httptest.NewRequest(method, target, body) req.RemoteAddr = "127.0.0.1:12345" if form != nil { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } for _, cookie := range cookies { req.AddCookie(cookie) } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, req) return recorder } func ensureCSRFCookie(t *testing.T, handler http.Handler, cookies ...*http.Cookie) *http.Cookie { t.Helper() for _, cookie := range cookies { if cookie != nil && cookie.Name == testCSRFCookieName && cookie.Value != "" { return cookie } } recorder := performRequest(t, handler, http.MethodGet, "/admin/login", nil, cookies...) if recorder.Code != http.StatusOK && recorder.Code != http.StatusSeeOther { t.Fatalf("expected csrf bootstrap request to succeed, got %d with body %s", recorder.Code, recorder.Body.String()) } for _, cookie := range recorder.Result().Cookies() { if cookie.Name == testCSRFCookieName && cookie.Value != "" { return cookie } } t.Fatal("expected csrf cookie from admin bootstrap request") return nil }