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:
+75
-11
@@ -14,6 +14,7 @@ import (
|
|||||||
cs "fn-registry/functions/cybersecurity"
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||||
|
"github.com/enmanuel/unibus/pkg/busauth"
|
||||||
"github.com/enmanuel/unibus/pkg/client"
|
"github.com/enmanuel/unibus/pkg/client"
|
||||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
"github.com/enmanuel/unibus/pkg/frame"
|
||||||
@@ -42,31 +43,49 @@ func freePort(t *testing.T) int {
|
|||||||
return l.Addr().(*net.TCPAddr).Port
|
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
|
// newHarnessMode is newHarness with an explicit control-plane auth mode and the
|
||||||
// tests can boot the real server in enforce/soft and exercise it through the
|
// NATS data plane left open (no nkey auth), so HTTP-auth tests can use a plain
|
||||||
// production client (which signs every request).
|
// client.New that does not present an nkey.
|
||||||
func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness {
|
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()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
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 {
|
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)
|
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"))
|
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ns.Shutdown()
|
ns.Shutdown()
|
||||||
|
store.Close()
|
||||||
t.Fatalf("blob store: %v", err)
|
t.Fatalf("blob store: %v", err)
|
||||||
}
|
}
|
||||||
srv := membership.NewServer(store, blobs, mode)
|
srv := membership.NewServer(store, blobs, ctrlMode)
|
||||||
httpts := httptest.NewServer(srv)
|
httpts := httptest.NewServer(srv)
|
||||||
|
|
||||||
h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store}
|
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 ---------------------------------------------------------
|
// ---- test helpers ---------------------------------------------------------
|
||||||
|
|
||||||
type collector struct {
|
type collector struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user