From 2130eaa44d2e919372e4177d37912352e4d62ace Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:31:58 +0200 Subject: [PATCH] test: control-plane auth middleware + end-to-end enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit membership/auth_test: golden (signed+registered accepted), error paths (unregistered 401, replayed nonce 401, clock skew 401, tampered body 401, missing headers 401), exemptions (healthz, soft allows, off no-op). client_test: end-to-end with the real client against an enforce server — registered peer accepted, unregistered rejected, revoked peer denied without a server restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/client/client_test.go | 67 ++++++++++++- pkg/membership/auth_test.go | 194 ++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 pkg/membership/auth_test.go diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 8de36fb..1766d4a 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -1,10 +1,12 @@ package client_test import ( + "encoding/hex" "net" "net/http" "net/http/httptest" "path/filepath" + "strings" "sync" "testing" "time" @@ -27,6 +29,7 @@ type testHarness struct { ctrlURL string ns *server.Server httpts *httptest.Server + store *membership.Store } func freePort(t *testing.T) int { @@ -39,7 +42,12 @@ func freePort(t *testing.T) int { return l.Addr().(*net.TCPAddr).Port } -func newHarness(t *testing.T) *testHarness { +func newHarness(t *testing.T) *testHarness { return newHarnessMode(t, membership.AuthOff) } + +// newHarnessMode is newHarness with an explicit control-plane auth mode, so auth +// tests can boot the real server in enforce/soft and exercise it through the +// production client (which signs every request). +func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness { t.Helper() dir := t.TempDir() @@ -58,10 +66,10 @@ func newHarness(t *testing.T) *testHarness { ns.Shutdown() t.Fatalf("blob store: %v", err) } - srv := membership.NewServer(store, blobs) + srv := membership.NewServer(store, blobs, mode) httpts := httptest.NewServer(srv) - h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts} + h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store} t.Cleanup(func() { httpts.Close() store.Close() @@ -71,6 +79,15 @@ func newHarness(t *testing.T) *testHarness { return h } +// registerClient adds a peer's signing identity to the bus allowlist so its +// signed control-plane requests pass under enforce. +func registerClient(t *testing.T, h *testHarness, c *client.Client, handle, role string) { + t.Helper() + if err := h.store.AddUser(hex.EncodeToString(c.Endpoint().SignPub), handle, role); err != nil { + t.Fatalf("register %s: %v", handle, err) + } +} + func waitHealth(t *testing.T, ctrlURL string) { t.Helper() deadline := time.Now().Add(3 * time.Second) @@ -455,6 +472,50 @@ func TestListMyRoomsDiscovery(t *testing.T) { } } +// TestControlPlaneAuthEnforceE2E closes the loop end to end with the production +// client against a server in enforce mode: a registered peer's signed requests +// are accepted (golden), and an unregistered peer is rejected with 401 on its +// first control-plane call (error path). This proves the client's real +// signature construction matches the server's verification. +func TestControlPlaneAuthEnforceE2E(t *testing.T) { + h := newHarnessMode(t, membership.AuthEnforce) + waitHealth(t, h.ctrlURL) + + a, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) + if err != nil { + t.Fatalf("connect A: %v", err) + } + defer a.Close() + registerClient(t, h, a, "alice", membership.RoleAdmin) + + // Golden: registered peer's signed request is accepted. + if _, err := a.CreateRoom("room.enforced", room.ModeNATS); err != nil { + t.Fatalf("registered peer should create a room under enforce: %v", err) + } + + // Error path: an unregistered peer is rejected on its first control-plane call. + b, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) + if err != nil { + t.Fatalf("connect B: %v", err) + } + defer b.Close() + _, err = b.CreateRoom("room.denied", room.ModeNATS) + if err == nil { + t.Fatalf("unregistered peer must be rejected under enforce") + } + if !strings.Contains(err.Error(), "401") && !strings.Contains(strings.ToLower(err.Error()), "unauthorized") { + t.Fatalf("expected a 401/unauthorized error, got %v", err) + } + + // Revocation takes effect without restart: revoke A, its next request fails. + if err := h.store.RevokeUser(hex.EncodeToString(a.Endpoint().SignPub)); err != nil { + t.Fatalf("revoke A: %v", err) + } + if _, err := a.CreateRoom("room.after-revoke", room.ModeNATS); err == nil { + t.Fatalf("revoked peer must be rejected without a server restart") + } +} + // ---- test helpers --------------------------------------------------------- type collector struct { diff --git a/pkg/membership/auth_test.go b/pkg/membership/auth_test.go new file mode 100644 index 0000000..6cc3445 --- /dev/null +++ b/pkg/membership/auth_test.go @@ -0,0 +1,194 @@ +package membership + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "testing" + "time" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/blobstore" +) + +// authHarness boots an in-process membershipd HTTP server in the given auth mode +// with a fresh store + blob store, and seeds one active admin ("alice"). +type authHarness struct { + ts *httptest.Server + store *Store + alice cs.Identity + alicePub string // hex +} + +func newAuthHarness(t *testing.T, mode AuthMode) *authHarness { + t.Helper() + dir := t.TempDir() + store, err := Open(filepath.Join(dir, "unibus.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + blobs, err := blobstore.New(filepath.Join(dir, "blobs")) + if err != nil { + t.Fatalf("open blobs: %v", err) + } + alice, err := cs.GenerateIdentity() + if err != nil { + t.Fatalf("identity: %v", err) + } + alicePub := hex.EncodeToString(alice.SignPub) + if err := store.AddUser(alicePub, "alice", RoleAdmin); err != nil { + t.Fatalf("seed admin: %v", err) + } + srv := NewServer(store, blobs, mode) + ts := httptest.NewServer(srv) + t.Cleanup(func() { + ts.Close() + store.Close() + }) + return &authHarness{ts: ts, store: store, alice: alice, alicePub: alicePub} +} + +// signedReq builds a control-plane request signed by id, with explicit ts/nonce +// so tests can force skew and replay. It signs via the same CanonicalRequest the +// production client uses, so the test verifies the real wire contract. +func signedReq(t *testing.T, base, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request { + t.Helper() + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, base+path, rdr) + if err != nil { + t.Fatalf("new request: %v", err) + } + tss := strconv.FormatInt(ts, 10) + canonical := CanonicalRequest(method, path, tss, nonce, body) + sig := cs.SignEd25519(id.SignPriv, canonical) + req.Header.Set(hdrPub, hex.EncodeToString(id.SignPub)) + req.Header.Set(hdrTs, tss) + req.Header.Set(hdrNonce, nonce) + req.Header.Set(hdrSig, base64.StdEncoding.EncodeToString(sig)) + return req +} + +func do(t *testing.T, req *http.Request) (int, string) { + t.Helper() + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +const okPath = "/members/alice-endpoint/rooms" // always 200 with an empty list + +// Golden: a request signed by a registered, active identity is accepted. +func TestAuthGoldenAccepted(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + now := time.Now().Unix() + code, _ := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-golden")) + if code != http.StatusOK { + t.Fatalf("golden signed request should be 200, got %d", code) + } +} + +// Error path: a structurally valid signature from an identity that is NOT in the +// allowlist is rejected with 401. +func TestAuthUnregisteredRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + bob, _ := cs.GenerateIdentity() + now := time.Now().Unix() + code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, bob, now, "nonce-bob")) + if code != http.StatusUnauthorized { + t.Fatalf("unregistered identity should be 401, got %d (%s)", code, body) + } +} + +// Error path: replaying a captured request (same nonce + signature) is rejected. +func TestAuthReplayRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + now := time.Now().Unix() + first := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay") + if code, body := do(t, first); code != http.StatusOK { + t.Fatalf("first request should be 200, got %d (%s)", code, body) + } + // Identical ts + nonce + signature: a replay. + second := signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, now, "nonce-replay") + if code, body := do(t, second); code != http.StatusUnauthorized { + t.Fatalf("replayed request should be 401, got %d (%s)", code, body) + } +} + +// Error path: a timestamp outside the ±30s window is rejected even with a valid +// signature (defends against long-delayed captured requests). +func TestAuthClockSkewRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + stale := time.Now().Unix() - 120 + code, body := do(t, signedReq(t, h.ts.URL, "GET", okPath, nil, h.alice, stale, "nonce-skew")) + if code != http.StatusUnauthorized { + t.Fatalf("clock-skewed request should be 401, got %d (%s)", code, body) + } +} + +// Error path: tampering the body after signing invalidates the signature. +func TestAuthTamperedBodyRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + now := time.Now().Unix() + req := signedReq(t, h.ts.URL, "POST", "/rooms", []byte(`{"subject":"x"}`), h.alice, now, "nonce-tamper") + // Swap the body for different bytes the signature does not cover. + req.Body = io.NopCloser(bytes.NewReader([]byte(`{"subject":"evil"}`))) + req.ContentLength = int64(len(`{"subject":"evil"}`)) + code, body := do(t, req) + if code != http.StatusUnauthorized { + t.Fatalf("tampered body should be 401, got %d (%s)", code, body) + } +} + +// Error path: missing auth headers under enforce are rejected. +func TestAuthMissingHeadersRejected(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) + code, _ := do(t, req) + if code != http.StatusUnauthorized { + t.Fatalf("unsigned request under enforce should be 401, got %d", code) + } +} + +// Exemption: the health probe bypasses auth even under enforce. +func TestAuthHealthExempt(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + req, _ := http.NewRequest("GET", h.ts.URL+"/healthz", nil) + code, _ := do(t, req) + if code != http.StatusOK { + t.Fatalf("/healthz must be reachable without auth, got %d", code) + } +} + +// Soft mode: an unauthenticated request is logged but allowed through, so +// clients can migrate without an outage. +func TestAuthSoftAllowsUnauthenticated(t *testing.T) { + h := newAuthHarness(t, AuthSoft) + req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) + code, _ := do(t, req) + if code != http.StatusOK { + t.Fatalf("soft mode should allow unsigned request, got %d", code) + } +} + +// Off mode (default for legacy callers): no verification at all. +func TestAuthOffNoVerification(t *testing.T) { + h := newAuthHarness(t, AuthOff) + req, _ := http.NewRequest("GET", h.ts.URL+okPath, nil) + code, _ := do(t, req) + if code != http.StatusOK { + t.Fatalf("off mode should allow unsigned request, got %d", code) + } +}