fix(14-05): surf answers OPTIONS on CORS paths with Laravel's HandleCors headers

- every OPTIONS on a CORS path: 204 with Cache-Control no-cache, private
- a preflight echoes the requested method (upper-cased) and headers when * allows any, with Vary and PHP's default Content-Type, as recorded from PHP
- README and the routing docs describe the answer
This commit is contained in:
Jakub Zych
2026-10-04 00:00:01 +02:00
parent 7eb0174612
commit b5d20b3bfd
4 changed files with 106 additions and 2 deletions

View File

@@ -90,3 +90,75 @@ func TestCORSAllowOriginExactAndPattern(t *testing.T) {
}
})
}
// 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)
}
}