package membership_test import ( "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/membership" ) // newCORSServer builds a control-plane server with the given CORS allowlist over a // throwaway store, and returns a live httptest server. /healthz is auth-exempt, so // the CORS tests can exercise the cross-origin pipeline without signing requests. func newCORSServer(t *testing.T, origins ...string) *httptest.Server { t.Helper() dir := t.TempDir() store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { store.Close() }) blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) srv := membership.NewServer(store, blobs, membership.AuthOff) srv.AllowedOrigins = origins ts := httptest.NewServer(srv) t.Cleanup(ts.Close) return ts } // TestCORSPreflightAllowedOrigin: a preflight (OPTIONS) from an allow-listed origin // is answered 204 with the Access-Control headers, and never reaches auth. This is // what lets the browser-native uniweb client call the control plane (issue // uniweb/0001). func TestCORSPreflightAllowedOrigin(t *testing.T) { const origin = "http://localhost:5173" ts := newCORSServer(t, origin) req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) req.Header.Set("Origin", origin) req.Header.Set("Access-Control-Request-Method", "POST") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("preflight: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { t.Fatalf("preflight status = %d, want 204", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { t.Fatalf("Allow-Origin = %q, want %q", got, origin) } if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { t.Fatalf("Allow-Methods missing on preflight") } // The control-plane request-auth headers a browser signs every request with must // be allow-listed, or the browser's preflight blocks the real request (the bug a // live browser surfaced: listRooms failed with "Failed to fetch"). if got := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Unibus-Sig") { t.Fatalf("Allow-Headers must include the X-Unibus-* auth headers, got %q", got) } } // TestCORSPreflightDisallowedOrigin: a preflight from an origin NOT in the allowlist // gets 403 and no Access-Control headers, so the browser blocks the real request. func TestCORSPreflightDisallowedOrigin(t *testing.T) { ts := newCORSServer(t, "http://localhost:5173") req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) req.Header.Set("Origin", "https://evil.example.com") req.Header.Set("Access-Control-Request-Method", "POST") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("preflight: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("disallowed preflight status = %d, want 403", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin leaked for disallowed origin: %q", got) } } // TestCORSActualRequestCarriesHeader: a real GET from an allow-listed origin is // served normally AND carries the Allow-Origin header so the browser accepts the // response. func TestCORSActualRequestCarriesHeader(t *testing.T) { const origin = "http://localhost:5173" ts := newCORSServer(t, origin) req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) req.Header.Set("Origin", origin) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("healthz status = %d, want 200", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { t.Fatalf("Allow-Origin = %q, want %q", got, origin) } } // TestCORSDisabledByDefault: with an empty allowlist no Access-Control header is // ever emitted (CORS off) and requests behave exactly as before. This guards the // opt-in invariant: untouched deployments are unaffected. func TestCORSDisabledByDefault(t *testing.T) { ts := newCORSServer(t) // no origins req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) req.Header.Set("Origin", "http://localhost:5173") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("healthz status = %d, want 200", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin emitted with CORS off: %q", got) } } // TestCORSNativeClientUnaffected: a request with no Origin header (a native Go/Kotlin // client) is processed normally and gets no CORS headers, even when an allowlist is // configured. func TestCORSNativeClientUnaffected(t *testing.T) { ts := newCORSServer(t, "http://localhost:5173") resp, err := http.Get(ts.URL + "/healthz") // no Origin header if err != nil { t.Fatalf("get: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("healthz status = %d, want 200", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Allow-Origin set for a no-Origin native client: %q", got) } }