ec8d34aaa1
Add Server.AllowedOrigins and an applyCORS step at the top of ServeHTTP so a browser SPA (uniweb) can call the control plane cross-origin: an allow-listed Origin gets the Access-Control-Allow-* headers, and a preflight (OPTIONS) is answered 204 before the rate limiter and auth ever run. A disallowed or missing origin gets no headers (preflight 403), so the browser blocks the request. Wire it through membershipd's --cors-origins flag (comma list, reusing splitRoutes as a generic parser). Empty allowlist = CORS off, no headers emitted, behavior identical to before: native Go/Kotlin clients send no Origin and are unaffected. Opt-in per deployment (issue uniweb/0001, Phase 0). Tests: preflight allow/deny, header on the real response, CORS-off default, and no-Origin native client unaffected.
151 lines
4.9 KiB
Go
151 lines
4.9 KiB
Go
package membership_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|