Files
unibus/pkg/membership/invites_test.go
T
egutierrez 52c80ac010 test(membership,client): invite lifecycle, register, hard-delete
- Store-level suite over BOTH backends (SQLite + JetStream KV): golden redeem,
  single-use rejection, unknown token, expired token (forced past), cancel, and
  hard-delete. Plus the burn-on-claim edge (redeem with an already-registered
  key spends the invite and returns ErrUserExists on both backends).
- HTTP suite: admin mints an invite, a brand-new identity redeems it UNSIGNED
  via /register, the user appears in the allowlist, a second redeem is 409,
  expired is 410, malformed keys are 400, a non-admin is 403 on all four admin
  routes, and DELETE /users purges (vs revoke's status flip).
- Client end-to-end: admin mints an invite, an unregistered joiner redeems it
  without any admin signature, appears in the allowlist, then is hard-deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:14:44 +02:00

187 lines
6.4 KiB
Go

package membership
import (
"encoding/hex"
"encoding/json"
"errors"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
)
// newIDHex generates a fresh identity and returns its signing and key-exchange
// public keys as lowercase hex — the two keys a client presents to /register.
func newIDHex(t *testing.T) (signPub, kexPub string) {
t.Helper()
id, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("identity: %v", err)
}
return hex.EncodeToString(id.SignPub), hex.EncodeToString(id.KexPub)
}
// inviteSuite drives the full invite lifecycle against any Store backend: mint,
// look up, redeem (which registers the user), reject a second redeem (single-use)
// and a non-existent token, reject an expired token (forced past via the
// backend-specific forceExpire closure), and hard-delete a user. It is shared by
// the SQLite and JetStream tests so both backends prove identical behavior.
func inviteSuite(t *testing.T, s Store, forceExpire func(token string)) {
t.Helper()
// Mint an invite fixing handle + role.
inv, err := s.CreateInvite("alice-new", RoleMember, 3600)
if err != nil {
t.Fatalf("CreateInvite: %v", err)
}
if len(inv.Token) != 64 {
t.Fatalf("token should be 64 hex chars, got %d (%q)", len(inv.Token), inv.Token)
}
if inv.Used {
t.Fatalf("fresh invite must not be used")
}
// GetInvite round-trips it.
got, err := s.GetInvite(inv.Token)
if err != nil || got.Handle != "alice-new" || got.Role != RoleMember {
t.Fatalf("GetInvite mismatch: %+v err=%v", got, err)
}
// Redeem it: the presented signing key joins the allowlist with the invite's
// handle and role.
signPub, kexPub := newIDHex(t)
if err := s.ConsumeInvite(inv.Token, signPub, kexPub); err != nil {
t.Fatalf("ConsumeInvite (golden): %v", err)
}
u, err := s.GetUser(signPub)
if err != nil {
t.Fatalf("GetUser after register: %v", err)
}
if u.Handle != "alice-new" || u.Role != RoleMember || u.Status != StatusActive {
t.Fatalf("registered user wrong: %+v", u)
}
if !s.IsAuthorized(signPub) {
t.Fatalf("registered user should be authorized")
}
// Single-use: redeeming the same token again (even with a different identity)
// is rejected as used.
sp2, kp2 := newIDHex(t)
if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) {
t.Fatalf("second redeem should be ErrInviteUsed, got %v", err)
}
if _, err := s.GetUser(sp2); !errors.Is(err, ErrNotFound) {
t.Fatalf("second identity must NOT be registered, got %v", err)
}
// Unknown token is ErrNotFound.
if err := s.ConsumeInvite("deadbeef", "ab", "cd"); !errors.Is(err, ErrNotFound) {
t.Fatalf("unknown token should be ErrNotFound, got %v", err)
}
// Expired invite: mint one, force its deadline into the past, redeem -> rejected.
exp, err := s.CreateInvite("late", RoleMember, 3600)
if err != nil {
t.Fatalf("CreateInvite expired: %v", err)
}
forceExpire(exp.Token)
sp3, kp3 := newIDHex(t)
if err := s.ConsumeInvite(exp.Token, sp3, kp3); !errors.Is(err, ErrInviteExpired) {
t.Fatalf("expired redeem should be ErrInviteExpired, got %v", err)
}
// CancelInvite removes a pending invite; redeeming it afterward is ErrNotFound.
canc, err := s.CreateInvite("cancelme", RoleMember, 3600)
if err != nil {
t.Fatalf("CreateInvite cancel: %v", err)
}
if err := s.CancelInvite(canc.Token); err != nil {
t.Fatalf("CancelInvite: %v", err)
}
if err := s.ConsumeInvite(canc.Token, sp3, kp3); !errors.Is(err, ErrNotFound) {
t.Fatalf("cancelled invite redeem should be ErrNotFound, got %v", err)
}
// Hard-delete the registered user: it disappears from the allowlist entirely.
if err := s.DeleteUser(signPub); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, err := s.GetUser(signPub); !errors.Is(err, ErrNotFound) {
t.Fatalf("deleted user should be ErrNotFound, got %v", err)
}
if s.IsAuthorized(signPub) {
t.Fatalf("deleted user must not be authorized")
}
// Deleting an unknown key is ErrNotFound.
if err := s.DeleteUser(signPub); !errors.Is(err, ErrNotFound) {
t.Fatalf("re-delete should be ErrNotFound, got %v", err)
}
}
// TestInvitesSQLite runs the suite against the default SQLite backend, forcing
// expiry with a direct UPDATE on the embedded DB (white-box, same package).
func TestInvitesSQLite(t *testing.T) {
s := openTestStore(t)
inviteSuite(t, s, func(token string) {
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
if _, err := s.db.Exec(`UPDATE invites SET expires_at = ? WHERE token = ?`, past, token); err != nil {
t.Fatalf("force expire: %v", err)
}
})
}
// TestInvitesJetStream runs the same suite against the replicated KV backend,
// forcing expiry by re-Putting the invite JSON with a past deadline.
func TestInvitesJetStream(t *testing.T) {
s, _, _ := newKVStore(t)
inviteSuite(t, s, func(token string) {
inv, err := s.GetInvite(token)
if err != nil {
t.Fatalf("force expire: get invite: %v", err)
}
inv.ExpiresAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
b, err := json.Marshal(inv)
if err != nil {
t.Fatalf("force expire: marshal: %v", err)
}
ctx, cancel := s.ctx()
defer cancel()
if _, err := s.invites.Put(ctx, token, b); err != nil {
t.Fatalf("force expire: put: %v", err)
}
})
}
// TestConsumeInvite_AlreadyRegistered covers the burn-on-claim edge: redeeming a
// valid invite with a signing key that is already registered surfaces
// ErrUserExists AND spends the invite (both backends behave identically).
func TestConsumeInvite_AlreadyRegistered(t *testing.T) {
for _, tc := range []struct {
name string
open func(t *testing.T) Store
}{
{"sqlite", func(t *testing.T) Store { return openTestStore(t) }},
{"jetstream", func(t *testing.T) Store { s, _, _ := newKVStore(t); return s }},
} {
t.Run(tc.name, func(t *testing.T) {
s := tc.open(t)
signPub, kexPub := newIDHex(t)
if err := s.AddUser(signPub, "existing", RoleMember); err != nil {
t.Fatalf("seed user: %v", err)
}
inv, err := s.CreateInvite("dup", RoleMember, 3600)
if err != nil {
t.Fatalf("CreateInvite: %v", err)
}
if err := s.ConsumeInvite(inv.Token, signPub, kexPub); !errors.Is(err, ErrUserExists) {
t.Fatalf("redeem with registered key should be ErrUserExists, got %v", err)
}
// The invite is spent (burn-on-claim): a fresh identity cannot reuse it.
sp2, kp2 := newIDHex(t)
if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) {
t.Fatalf("invite should be spent after a burned claim, got %v", err)
}
})
}
}