package httpserver_test import ( "net/http" "net/url" "testing" "update_server/internal/apikeys" "update_server/internal/config" "update_server/internal/db" ) func TestAdminPOSTRejectsMissingOrInvalidCSRFToken(t *testing.T) { t.Parallel() router, _, _ := newTestRouterWithStore(t) sessionCookie := loginAsAdmin(t, router) missingRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Desktop App"}, "slug": {"desktop-app"}, }, sessionCookie) if missingRecorder.Code != http.StatusForbidden { t.Fatalf("expected missing csrf token to return 403, got %d with body %s", missingRecorder.Code, missingRecorder.Body.String()) } csrfCookie := ensureCSRFCookie(t, router, sessionCookie) invalidRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Desktop App"}, "slug": {"desktop-app"}, "csrf_token": {"definitely-wrong"}, }, sessionCookie, csrfCookie) if invalidRecorder.Code != http.StatusForbidden { t.Fatalf("expected invalid csrf token to return 403, got %d with body %s", invalidRecorder.Code, invalidRecorder.Body.String()) } location := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{ "name": {"Desktop App"}, "slug": {"desktop-app"}, }, http.StatusSeeOther, sessionCookie) if location == "" { t.Fatal("expected valid csrf-protected form to redirect") } } func TestSecurityHeadersAndCookieDefaults(t *testing.T) { t.Parallel() router, cfg, store := newTestRouterWithConfig(t, func(cfg *config.Config) { cfg.BaseURL = "https://updates.example.com" cfg.SecureCookies = true }) loginPageRecorder := performRequest(t, router, http.MethodGet, "/admin/login", nil) if loginPageRecorder.Code != http.StatusOK { t.Fatalf("expected login page to load, got %d", loginPageRecorder.Code) } assertHeaderContains(t, loginPageRecorder, "Cache-Control", "no-store") assertHeaderEquals(t, loginPageRecorder, "X-Frame-Options", "DENY") assertHeaderEquals(t, loginPageRecorder, "X-Content-Type-Options", "nosniff") assertHeaderEquals(t, loginPageRecorder, "Referrer-Policy", "no-referrer") assertHeaderContains(t, loginPageRecorder, "Content-Security-Policy", "frame-ancestors 'none'") assertHeaderContains(t, loginPageRecorder, "Strict-Transport-Security", "max-age=31536000") csrfCookie := ensureCSRFCookie(t, router) if csrfCookie.Path != "/admin" { t.Fatalf("expected csrf cookie path /admin, got %q", csrfCookie.Path) } if !csrfCookie.HttpOnly { t.Fatal("expected csrf cookie to be HttpOnly") } if !csrfCookie.Secure { t.Fatal("expected csrf cookie to be Secure when APP_BASE_URL is https") } if csrfCookie.SameSite != http.SameSiteStrictMode { t.Fatalf("expected csrf cookie SameSite=Strict, got %v", csrfCookie.SameSite) } loginRecorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{ "email": {"admin@example.com"}, "password": {"correct horse battery staple"}, "next": {"/admin"}, "csrf_token": {csrfCookie.Value}, }, csrfCookie) if loginRecorder.Code != http.StatusSeeOther { t.Fatalf("expected login to redirect, got %d with body %s", loginRecorder.Code, loginRecorder.Body.String()) } var sessionCookie *http.Cookie for _, cookie := range loginRecorder.Result().Cookies() { if cookie.Name == cfg.SessionCookieName { sessionCookie = cookie break } } if sessionCookie == nil { t.Fatal("expected session cookie after login") } if sessionCookie.Path != "/admin" { t.Fatalf("expected session cookie path /admin, got %q", sessionCookie.Path) } if !sessionCookie.HttpOnly { t.Fatal("expected session cookie to be HttpOnly") } if !sessionCookie.Secure { t.Fatal("expected session cookie to be Secure") } if sessionCookie.SameSite != http.SameSiteLaxMode { t.Fatalf("expected session cookie SameSite=Lax, got %v", sessionCookie.SameSite) } keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ Name: "Clients", ScopeMode: db.ScopeModeAllProjects, CanDownload: true, }) if err != nil { t.Fatalf("create api key: %v", err) } apiRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) if apiRecorder.Code != http.StatusOK { t.Fatalf("expected api project listing to succeed, got %d with body %s", apiRecorder.Code, apiRecorder.Body.String()) } assertHeaderContains(t, apiRecorder, "Cache-Control", "no-store") assertHeaderContains(t, apiRecorder, "Vary", "Authorization") assertHeaderEquals(t, apiRecorder, "X-Content-Type-Options", "nosniff") assertHeaderEquals(t, apiRecorder, "X-Frame-Options", "DENY") assertHeaderContains(t, apiRecorder, "X-Robots-Tag", "noindex") } func TestLoginRateLimitReturnsTooManyRequests(t *testing.T) { t.Parallel() router, _, _ := newTestRouterWithConfig(t, func(cfg *config.Config) { cfg.LoginRateLimitPerMinute = 60 cfg.LoginRateLimitBurst = 2 }) csrfCookie := ensureCSRFCookie(t, router) form := url.Values{ "email": {"admin@example.com"}, "password": {"wrong-password"}, "next": {"/admin"}, "csrf_token": {csrfCookie.Value}, } for attempt := 0; attempt < 2; attempt++ { recorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie) if recorder.Code != http.StatusUnauthorized { t.Fatalf("expected attempt %d to return 401, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String()) } } limitedRecorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie) if limitedRecorder.Code != http.StatusTooManyRequests { t.Fatalf("expected limited login to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String()) } assertHeaderContains(t, limitedRecorder, "Retry-After", "1") } func TestClientAPIRateLimitReturnsTooManyRequests(t *testing.T) { t.Parallel() router, _, store := newTestRouterWithConfig(t, func(cfg *config.Config) { cfg.ClientRateLimitPerMinute = 60 cfg.ClientRateLimitBurst = 2 }) keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{ Name: "Clients", ScopeMode: db.ScopeModeAllProjects, CanDownload: true, }) if err != nil { t.Fatalf("create api key: %v", err) } for attempt := 0; attempt < 2; attempt++ { recorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) if recorder.Code != http.StatusOK { t.Fatalf("expected api attempt %d to return 200, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String()) } } limitedRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey) if limitedRecorder.Code != http.StatusTooManyRequests { t.Fatalf("expected api rate limit to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String()) } assertHeaderContains(t, limitedRecorder, "Retry-After", "1") }