Files
unibus/pkg/membership/cors_test.go
T
egutierrez 294905984c fix(membership): allow X-Unibus-* auth headers in CORS preflight
A browser signs every control-plane request with X-Unibus-Pub/Ts/Nonce/Sig
(busauth.signedHeaders). The CORS Allow-Headers only listed Content-Type and
Authorization, so the browser's preflight rejected the real request and the SPA
failed with 'Failed to fetch' on the first authenticated call (listRooms). Add the
four X-Unibus-* headers to Access-Control-Allow-Headers.

This was invisible to the Node smoke (fetch in Node does no CORS preflight); only a
real browser surfaced it. Verified live: enmanuel logs into uniweb against the
cluster and lists rooms. Regression test asserts the header is present.
2026-06-14 12:12:20 +02:00

158 lines
5.3 KiB
Go

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)
}
}