package surf import ( "net/http" "net/http/httptest" "testing" ) // Gap (d): pathScopedCORS with a path matching NONE of the configured // globs. TestCORSPathScopedHeaders already covers the two named acme // groups; this fixture is framework-only (/healthz vs api/*). func TestCORSPathScopedNoMatchIndependentOfAcme(t *testing.T) { cfg := CORSConfig{ Paths: []string{"api/*", "oauth/mcp/*"}, AllowedMethods: []string{"*"}, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"*"}, } inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) h := pathScopedCORS(cfg, inner) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if rec.Code != http.StatusNoContent { t.Fatalf("status = %d", rec.Code) } if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("unmatched path must not set ACAO, got %q", got) } } func TestCORSAllowOriginExactAndPattern(t *testing.T) { cfg := CORSConfig{ Paths: []string{"api/*"}, AllowedMethods: []string{"GET", "POST"}, AllowedOrigins: []string{"https://app.example.test"}, AllowedOriginsPatterns: []string{`^https://.*\.example\.test$`}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"X-RateLimit-Limit"}, MaxAge: 600, SupportsCredentials: true, } inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) h := pathScopedCORS(cfg, inner) t.Run("exact origin", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil) req.Header.Set("Origin", "https://app.example.test") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Header().Get("Access-Control-Allow-Origin") != "https://app.example.test" { t.Fatalf("ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin")) } if rec.Header().Get("Vary") != "Origin" { t.Fatalf("Vary = %q", rec.Header().Get("Vary")) } if rec.Header().Get("Access-Control-Allow-Credentials") != "true" { t.Fatal("missing credentials header") } if rec.Header().Get("Access-Control-Max-Age") != "600" { t.Fatalf("Max-Age = %q", rec.Header().Get("Access-Control-Max-Age")) } }) t.Run("pattern origin", func(t *testing.T) { req := httptest.NewRequest(http.MethodOptions, "/api/v1/items", nil) req.Header.Set("Origin", "https://admin.example.test") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("preflight status = %d", rec.Code) } if rec.Header().Get("Access-Control-Allow-Origin") != "https://admin.example.test" { t.Fatalf("ACAO = %q", rec.Header().Get("Access-Control-Allow-Origin")) } }) t.Run("disallowed origin", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil) req.Header.Set("Origin", "https://evil.test") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("disallowed origin ACAO = %q", got) } }) } // TestCORSOptionsMatchesLaravel pins the OPTIONS answers Laravel's // HandleCors gives (recorded from PHP for the feedback widget): a // preflight echoes the requested method and headers when any is allowed, // with PHP's default Content-Type and Symfony's Cache-Control; a plain // OPTIONS is a bare 204 with the Cache-Control. func TestCORSOptionsMatchesLaravel(t *testing.T) { cfg := CORSConfig{ Paths: []string{"_feedback/api/*"}, AllowedMethods: []string{"*"}, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"*"}, } called := false h := pathScopedCORS(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) req := httptest.NewRequest(http.MethodOptions, "/_feedback/api/v1/wk_key/submit", nil) req.Header.Set("Origin", "https://app.example.test") req.Header.Set("Access-Control-Request-Method", "post") req.Header.Set("Access-Control-Request-Headers", "content-type") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) want := map[string]string{ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST", "Access-Control-Allow-Headers": "content-type", "Cache-Control": "no-cache, private", "Content-Type": "text/html; charset=UTF-8", "Vary": "Access-Control-Request-Method, Access-Control-Request-Headers", } if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { t.Fatalf("preflight = %d %q", rec.Code, rec.Body.String()) } for k, v := range want { if got := rec.Header().Get(k); got != v { t.Errorf("preflight %s = %q, want %q", k, got, v) } } rec = httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodOptions, "/_feedback/api/v1/anything/else", nil)) if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { t.Fatalf("plain OPTIONS = %d %q", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Cache-Control"); got != "no-cache, private" { t.Errorf("plain Cache-Control = %q", got) } if got := rec.Header().Get("Content-Type"); got != "" { t.Errorf("plain Content-Type = %q", got) } if got := rec.Header().Get("Vary"); got != "Access-Control-Request-Method" { t.Errorf("plain Vary = %q", got) } if called { t.Fatal("an OPTIONS request reached the route handler") } // A listed method set is sent as configured, not echoed. listed := pathScopedCORS(CORSConfig{Paths: []string{"api/*"}, AllowedMethods: []string{"GET", "POST"}, AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"Authorization"}}, http.NotFoundHandler()) req = httptest.NewRequest(http.MethodOptions, "/api/items", nil) req.Header.Set("Origin", "https://app.example.test") req.Header.Set("Access-Control-Request-Method", "DELETE") req.Header.Set("Access-Control-Request-Headers", "x-custom") rec = httptest.NewRecorder() listed.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "GET, POST" { t.Errorf("listed methods = %q", got) } if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "Authorization" { t.Errorf("listed headers = %q", got) } }