package main // Integration tests for issue 0011 GAP A: `membershipd user add --store kv` // adds users to a RUNNING cluster's replicated allowlist via the privileged // internal connection, instead of the stop-seed-restart procedure the 0011 // deploy required. These exercise the real connectKVStore path (load the // persisted internal identity from a file, present its nkey, open the KV store, // write the user) against an embedded enforce node, plus the idempotency and // error semantics the DoD calls for. Multi-node replication and node-down quorum // are validated against the live cluster (report 0012). import ( "encoding/hex" "errors" "path/filepath" "testing" "time" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/membership" ) // startEnforceKVNode boots a single embedded enforce node whose authenticator // recognizes internalPubHex as the privileged internal identity, bootstraps the // KV control-plane store over the in-process internal connection, and publishes // it into the holder — the exact sequence main.go performs for --store kv. It // returns the client URL the CLI connects to. func startEnforceKVNode(t *testing.T, internalID cs.Identity) string { t.Helper() holder := &storeHolder{} auth := busauth.NewNkeyAuthenticatorACLInternal( holder.IsAuthorized, busauth.PermissionsFromSubjects(holder.subjectACL), hex.EncodeToString(internalID.SignPub), ) ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth, }) if err != nil { t.Fatalf("start enforce node: %v", err) } t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) intNC, js, err := connectInternalJS(ns, internalID, true) if err != nil { t.Fatalf("bootstrap internal connection: %v", err) } t.Cleanup(intNC.Close) kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second}) if err != nil { t.Fatalf("bootstrap KV store: %v", err) } holder.set(kvStore) return ns.ClientURL() } // TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI // connection (real connectKVStore, loading the internal identity from a file and // presenting its nkey) writes a user into the live KV allowlist, the user is // authorized afterward, and re-adding the same key is an explicit ErrUserExists // with no corruption (the unchanged row is still authorized). func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) { idFile := filepath.Join(t.TempDir(), "internal.id") internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600 if err != nil { t.Fatalf("persist internal identity: %v", err) } url := startEnforceKVNode(t, internalID) // Golden: connect as the privileged internal identity (loopback, no TLS) and // add a new user, exactly as `user add --store kv` does. kv, err := connectKVStore(url, idFile, "", 1) if err != nil { t.Fatalf("connectKVStore (privileged): %v", err) } defer kv.Close() newUser, err := cs.GenerateIdentity() if err != nil { t.Fatalf("new user identity: %v", err) } pub := hex.EncodeToString(newUser.SignPub) if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil { t.Fatalf("add user to live KV: %v", err) } if !kv.store.IsAuthorized(pub) { t.Fatalf("user added to KV must be authorized") } // Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists. err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember) if !errors.Is(err, membership.ErrUserExists) { t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err) } // A different handle/role with the SAME key is also rejected — the row is not // silently overwritten (no role flip). if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) { t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err) } u, err := kv.store.GetUser(pub) if err != nil { t.Fatalf("get user: %v", err) } if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive { t.Fatalf("idempotent re-add corrupted the row: %+v", u) } } // TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable // internal identity file fails loudly (missing file, empty path) rather than // silently connecting unprivileged. func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) { if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil { t.Fatalf("empty --internal-id-file must be an error") } missing := filepath.Join(t.TempDir(), "nope.id") if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil { t.Fatalf("missing internal identity file must be an error") } } // TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv // at a dead endpoint yields a clear, handled error (no crash, no silent success). func TestUserAddStoreKV_UnreachableKV(t *testing.T) { idFile := filepath.Join(t.TempDir(), "internal.id") if _, err := client.LoadOrCreateIdentity(idFile); err != nil { t.Fatalf("persist internal identity: %v", err) } // A loopback port with nothing listening: connect must fail fast and wrapped. _, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1) if err == nil { t.Fatalf("connecting to a dead endpoint must error") } } // TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca // is refused so the allowlist write never travels in cleartext (audit 0008 N6, // same guard as migrate-to-kv). func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) { idFile := filepath.Join(t.TempDir(), "internal.id") if _, err := client.LoadOrCreateIdentity(idFile); err != nil { t.Fatalf("persist internal identity: %v", err) } _, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1) if err == nil { t.Fatalf("remote target without --ca must be refused") } }