package httpserver_test import ( "net/http" "net/http/httptest" "testing" "github.com/krow/krow-backend/go-api/internal/testutil" ) const devOrigin = "http://localhost:5173" // corsAPI is newAPI with an explicit CORS allowlist. It is separate because // every other test in this package asserts the same-origin behaviour, where the // middleware is not installed at all. func corsAPI(t *testing.T, origins ...string) http.Handler { t.Helper() h := testutil.New(t) srv := newServer(t, h, origins) handler := srv.Handler() // The API routes below now require a session. Signing in once and attaching // the cookie to every request keeps these tests about CORS: without it they // would assert 401 and prove nothing about the headers. userID, email := seededUser(t, h.Pool) setPassword(t, h.Pool, userID) result := signIn(t, handler, email, harnessPassword, false) if result.code != http.StatusOK || result.cookie == nil { t.Fatalf("the CORS harness could not sign in: status %d", result.code) } return withSession(handler, result.cookie) } func send(handler http.Handler, method, path string, headers map[string]string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, path, nil) for k, v := range headers { req.Header.Set(k, v) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) return rec } // An allowed origin gets its own origin echoed back, never "*". func TestCORSAllowsConfiguredOrigin(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin}) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != devOrigin { t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, devOrigin) } if rec.Header().Get("Vary") == "" { t.Fatal("a response that varies by Origin must say so") } } // The preflight the browser sends before a PATCH must succeed without reaching // the router, and must name the methods the frontend uses. func TestCORSPreflight(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "OPTIONS", "/api/v1/job-applications/some-id", map[string]string{ "Origin": devOrigin, "Access-Control-Request-Method": "PATCH", "Access-Control-Request-Headers": "content-type", }) if rec.Code != http.StatusNoContent { t.Fatalf("preflight: expected 204, got %d (%s)", rec.Code, rec.Body.String()) } allow := rec.Header().Get("Access-Control-Allow-Methods") for _, m := range []string{"GET", "POST", "PATCH", "DELETE"} { if !contains(allow, m) { t.Fatalf("Access-Control-Allow-Methods = %q, missing %s", allow, m) } } if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type" { t.Fatalf("Access-Control-Allow-Headers = %q, want the requested header echoed", got) } if rec.Header().Get("Access-Control-Max-Age") == "" { t.Fatal("preflight result should be cacheable") } } // An origin that is not on the list gets no CORS headers, so the browser will // not hand the response to the page. func TestCORSRefusesUnknownOrigin(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{ "Origin": "http://evil.example", }) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("an unlisted origin was allowed: %q", got) } pre := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{ "Origin": "http://evil.example", "Access-Control-Request-Method": "GET", }) if pre.Code != http.StatusForbidden { t.Fatalf("preflight from an unlisted origin: expected 403, got %d", pre.Code) } } // A caller with no Origin — curl, a health checker, anything server-to-server — // is untouched by the middleware. func TestCORSIgnoresRequestsWithoutOrigin(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "GET", "/health", nil) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("a request with no Origin got CORS headers: %q", got) } } // With no allowlist the middleware is not installed, which is the posture for // any deployment serving the frontend from the API's own origin. func TestCORSOffByDefault(t *testing.T) { handler := corsAPI(t) // no origins rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin}) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("CORS answered with no allowlist configured: %q", got) } } // Authentication is a cookie, so a cross-origin frontend calling with // `credentials: 'include'` needs Access-Control-Allow-Credentials on the actual // response. Without it the browser blocks the page from reading a reply the // server answered perfectly well, and the app sees an opaque network failure // beside a 200 in the server log. func TestCORSAllowsCredentialsOnResponse(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": devOrigin}) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { t.Fatalf("Access-Control-Allow-Credentials = %q, want \"true\" — "+ "a cookie-authenticated API is unreadable cross-origin without it", got) } // Allow-Credentials with a wildcard origin is rejected by every browser, so // the two must never appear together. if got := rec.Header().Get("Access-Control-Allow-Origin"); got == "*" { t.Fatal("Access-Control-Allow-Origin is \"*\" alongside credentials; browsers refuse that pairing") } } // The preflight decides whether the browser is willing to SEND the cookie at // all, so it needs the header too — separately from the actual response. func TestCORSAllowsCredentialsOnPreflight(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "OPTIONS", "/api/v1/job-postings", map[string]string{ "Origin": devOrigin, "Access-Control-Request-Method": "POST", }) if rec.Code != http.StatusNoContent { t.Fatalf("expected 204, got %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" { t.Fatalf("preflight Access-Control-Allow-Credentials = %q, want \"true\"", got) } } // An origin that is not on the allowlist must not be handed credentials // permission — the header is worthless on its own, but pairing it with a // reflected origin would be the classic misconfiguration. func TestCORSWithholdsCredentialsFromUnknownOrigin(t *testing.T) { handler := corsAPI(t, devOrigin) rec := send(handler, "GET", "/api/v1/job-postings", map[string]string{"Origin": "http://evil.example"}) if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" { t.Fatalf("an unlisted origin was granted credentials: %q", got) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("an unlisted origin was echoed back: %q", got) } } func contains(haystack, needle string) bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false }