52c80ac010
- 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>
187 lines
6.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|