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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:37:59 +02:00
parent 1630f6f163
commit 00058ea0af
+75 -11
View File
@@ -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 {