From 00058ea0af2f52a7752f1295f367e4970ce3f8e3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:37:59 +0200 Subject: [PATCH] test(client): NATS nkey auth end to end Harness gains newHarnessFull(ctrlMode, natsAuth) wiring the nkey authenticator to the user allowlist; NATS auth and HTTP auth are independent so each plane is tested in isolation. TestNatsNkeyAuth: registered peer connects with nkey and operates (golden); unregistered peer and no-nkey client refused at connect (error paths); peer revoked at runtime refused on its next connection without a restart (edge). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/client/client_test.go | 86 ++++++++++++++++++++++++++++++++++----- 1 file changed, 75 insertions(+), 11 deletions(-) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 1766d4a..00d199c 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -14,6 +14,7 @@ import ( cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/blobstore" + "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/frame" @@ -42,31 +43,49 @@ func freePort(t *testing.T) int { return l.Addr().(*net.TCPAddr).Port } -func newHarness(t *testing.T) *testHarness { return newHarnessMode(t, membership.AuthOff) } +func newHarness(t *testing.T) *testHarness { return newHarnessFull(t, membership.AuthOff, false) } -// 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). +// newHarnessMode is newHarness with an explicit control-plane auth mode and the +// NATS data plane left open (no nkey auth), so HTTP-auth tests can use a plain +// client.New that does not present an nkey. func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness { + return newHarnessFull(t, mode, false) +} + +// newHarnessFull boots the embedded NATS (optionally with the nkey authenticator +// backed by the user allowlist) and the membershipd HTTP server in ctrlMode. +// natsAuth and ctrlMode are independent on purpose: an HTTP-enforce test can +// keep NATS open, and an nkey test can keep HTTP off, mirroring how the rollout +// flags compose. The store is created before NATS so the authenticator can +// consult IsAuthorized for live revocation. +func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) *testHarness { t.Helper() dir := t.TempDir() - ns, err := embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { + t.Fatalf("membership store: %v", err) + } + + var ns *server.Server + if natsAuth { + ns, err = embeddednats.StartHostAuth(filepath.Join(dir, "js"), "127.0.0.1", freePort(t), + busauth.NewNkeyAuthenticator(store.IsAuthorized)) + } else { + ns, err = embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + } + if err != nil { + store.Close() t.Fatalf("embedded nats: %v", err) } - store, err := membership.Open(filepath.Join(dir, "unibus.db")) - if err != nil { - ns.Shutdown() - t.Fatalf("membership store: %v", err) - } blobs, err := blobstore.New(filepath.Join(dir, "blobs")) if err != nil { ns.Shutdown() + store.Close() t.Fatalf("blob store: %v", err) } - srv := membership.NewServer(store, blobs, mode) + srv := membership.NewServer(store, blobs, ctrlMode) httpts := httptest.NewServer(srv) h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store} @@ -516,6 +535,51 @@ func TestControlPlaneAuthEnforceE2E(t *testing.T) { } } +// TestNatsNkeyAuth exercises the data-plane authenticator: with NATS nkey auth +// on, a registered peer connecting with its nkey is accepted and can publish +// (golden); an unregistered peer is refused at connect time (error path); and a +// peer revoked while the server runs is refused on its NEXT connection, proving +// revocation without a restart (edge). +func TestNatsNkeyAuth(t *testing.T) { + h := newHarnessFull(t, membership.AuthOff, true) // NATS auth on; HTTP off to isolate the data plane + waitHealth(t, h.ctrlURL) + + idA := mustIdentity(t) + if err := h.store.AddUser(hex.EncodeToString(idA.SignPub), "alice", membership.RoleMember); err != nil { + t.Fatalf("register A: %v", err) + } + + // Golden: registered peer connects with its nkey and uses the bus. + a, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idA, client.Options{UseNkey: true}) + if err != nil { + t.Fatalf("registered peer should connect with nkey: %v", err) + } + defer a.Close() + if _, err := a.CreateRoom("room.nkey", room.ModeNATS); err != nil { + t.Fatalf("registered peer should operate: %v", err) + } + + // Error path: an unregistered identity is refused at connect time. + idB := mustIdentity(t) + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idB, client.Options{UseNkey: true}); err == nil { + t.Fatalf("unregistered peer must be refused by the NATS authenticator") + } + + // Error path: presenting no nkey to an auth-required server is refused. + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idB, client.Options{UseNkey: false}); err == nil { + t.Fatalf("a client without an nkey must be refused when the server requires auth") + } + + // Edge: revoke A while the server runs; A's NEXT connection is refused even + // though an already-open connection (a) is unaffected. No server restart. + if err := h.store.RevokeUser(hex.EncodeToString(idA.SignPub)); err != nil { + t.Fatalf("revoke A: %v", err) + } + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idA, client.Options{UseNkey: true}); err == nil { + t.Fatalf("revoked peer must be refused on a new connection without a restart") + } +} + // ---- test helpers --------------------------------------------------------- type collector struct {