669bad52af
Add `membershipd bot add --handle <name> --out <path> [--role] [--store]` to provision a bus identity for an automated process in a single step: mint a fresh Ed25519+X25519 identity (cs.GenerateIdentity, the same derivation worker/chat use), register its signing key in the allowlist, and write the credentials to a 0600 file. The file is the canonical identity format read by client.LoadIdentity, so a worker/clientcheck binary pointed at --out connects as the new user with no extra conversion. Shares the sqlite/kv store plumbing with `user add`. New exported pkg/client.WriteNewIdentity writes an identity in that format but refuses to overwrite an existing file (never silently clobber private keys). provisionBot ordering guarantees no half-provisioned bot: refuse an existing --out before touching the store, register (an already-registered key is a clear error, not a panic), then write credentials. Tests cover the golden path (register + 0600 file + LoadIdentity round-trip), default role, the already-registered error path (no file written), and the out-exists error path (no orphan user). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
4.8 KiB
Go
150 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/client"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
)
|
|
|
|
// openTestStore opens a fresh SQLite membership store in a temp dir.
|
|
func openTestStore(t *testing.T) membership.Store {
|
|
t.Helper()
|
|
store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db"))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
return store
|
|
}
|
|
|
|
// TestProvisionBotGolden is the happy path: provisioning a bot registers it in the
|
|
// allowlist with the right handle and role, AND writes a 0600 credentials file
|
|
// that LoadIdentity reconstructs into the same identity — so a worker/clientcheck
|
|
// binary pointed at the file connects as exactly this user with no extra step.
|
|
func TestProvisionBotGolden(t *testing.T) {
|
|
store := openTestStore(t)
|
|
out := filepath.Join(t.TempDir(), "notifier.id")
|
|
|
|
signPubHex, endpoint, err := provisionBot(store, "notifier", membership.RoleMember, out)
|
|
if err != nil {
|
|
t.Fatalf("provisionBot: %v", err)
|
|
}
|
|
|
|
// Registered in the allowlist with the right handle/role/status.
|
|
u, err := store.GetUser(signPubHex)
|
|
if err != nil {
|
|
t.Fatalf("get provisioned user: %v", err)
|
|
}
|
|
if u.Handle != "notifier" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
|
|
t.Fatalf("provisioned user row wrong: %+v", u)
|
|
}
|
|
|
|
// And it shows up in user list (the `user list` surface).
|
|
users, err := store.ListUsers()
|
|
if err != nil {
|
|
t.Fatalf("list users: %v", err)
|
|
}
|
|
found := false
|
|
for _, x := range users {
|
|
if x.SignPub == signPubHex {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("provisioned bot missing from user list: %+v", users)
|
|
}
|
|
|
|
// Credentials file exists, is 0600, and round-trips through LoadIdentity to the
|
|
// same signing key + endpoint (no-friction contract).
|
|
info, err := os.Stat(out)
|
|
if err != nil {
|
|
t.Fatalf("stat out file: %v", err)
|
|
}
|
|
if perm := info.Mode().Perm(); perm != 0o600 {
|
|
t.Fatalf("out file perms = %o, want 600", perm)
|
|
}
|
|
id, err := client.LoadIdentity(out)
|
|
if err != nil {
|
|
t.Fatalf("LoadIdentity(out): %v", err)
|
|
}
|
|
if got := hex.EncodeToString(id.SignPub); got != signPubHex {
|
|
t.Fatalf("loaded sign_pub %q != provisioned %q", got, signPubHex)
|
|
}
|
|
if got := frame.EndpointID(id.SignPub); got != endpoint {
|
|
t.Fatalf("loaded endpoint %q != reported %q", got, endpoint)
|
|
}
|
|
}
|
|
|
|
// TestProvisionBotDefaultRole: an empty role defaults to member.
|
|
func TestProvisionBotDefaultRole(t *testing.T) {
|
|
store := openTestStore(t)
|
|
out := filepath.Join(t.TempDir(), "bot.id")
|
|
signPubHex, _, err := provisionBot(store, "defrole", "", out)
|
|
if err != nil {
|
|
t.Fatalf("provisionBot: %v", err)
|
|
}
|
|
u, err := store.GetUser(signPubHex)
|
|
if err != nil {
|
|
t.Fatalf("get user: %v", err)
|
|
}
|
|
if u.Role != membership.RoleMember {
|
|
t.Fatalf("empty role should default to member, got %q", u.Role)
|
|
}
|
|
}
|
|
|
|
// TestProvisionBotSignPubAlreadyRegistered is the error path: provisioning an
|
|
// identity whose signing key is already in the allowlist fails with a clear error
|
|
// (not a panic) AND does not write a credentials file (no half-provisioned bot).
|
|
func TestProvisionBotSignPubAlreadyRegistered(t *testing.T) {
|
|
store := openTestStore(t)
|
|
|
|
// Pre-register a key, then try to provision a bot with that SAME identity.
|
|
id, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("generate identity: %v", err)
|
|
}
|
|
signPubHex := hex.EncodeToString(id.SignPub)
|
|
if err := store.AddUser(signPubHex, "preexisting", membership.RoleMember); err != nil {
|
|
t.Fatalf("pre-register: %v", err)
|
|
}
|
|
|
|
out := filepath.Join(t.TempDir(), "dup.id")
|
|
_, _, err = provisionBotWithIdentity(store, id, "dupbot", membership.RoleMember, out)
|
|
if err == nil {
|
|
t.Fatalf("provisioning an already-registered key should error")
|
|
}
|
|
if _, statErr := os.Stat(out); !os.IsNotExist(statErr) {
|
|
t.Fatalf("credentials file must NOT be written on a duplicate-key failure (stat err = %v)", statErr)
|
|
}
|
|
}
|
|
|
|
// TestProvisionBotOutExists is the other error path: an existing --out file is
|
|
// refused BEFORE the store is mutated, so the run leaves no orphan user behind.
|
|
func TestProvisionBotOutExists(t *testing.T) {
|
|
store := openTestStore(t)
|
|
out := filepath.Join(t.TempDir(), "taken.id")
|
|
if err := os.WriteFile(out, []byte("preexisting credentials"), 0o600); err != nil {
|
|
t.Fatalf("seed out file: %v", err)
|
|
}
|
|
|
|
_, _, err := provisionBot(store, "clobber", membership.RoleMember, out)
|
|
if err == nil {
|
|
t.Fatalf("provisioning over an existing out file should error")
|
|
}
|
|
// The store must be untouched: no user was registered.
|
|
users, err := store.ListUsers()
|
|
if err != nil {
|
|
t.Fatalf("list users: %v", err)
|
|
}
|
|
if len(users) != 0 {
|
|
t.Fatalf("no user should be registered when out exists, got %+v", users)
|
|
}
|
|
}
|