Files
unibus/pkg/client/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

105 lines
3.4 KiB
Go

package client_test
import (
"encoding/hex"
"testing"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/membership"
)
// TestClientInvitesAdminAPI drives the wallet-model account flow through the real
// pkg/client methods against an in-process membershipd under enforce: an admin
// mints an invite, a brand-new identity redeems it via the UNSIGNED Register call
// (it is not yet in the allowlist), the admin then sees the user, and finally the
// admin hard-deletes it and it vanishes. This is the exact path the admin panel +
// the /join client page depend on, so it locks the client/server contract.
func TestClientInvitesAdminAPI(t *testing.T) {
h := newHarnessMode(t, membership.AuthEnforce)
waitHealth(t, h.ctrlURL)
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
if err != nil {
t.Fatalf("connect admin: %v", err)
}
defer admin.Close()
registerClient(t, h, admin, "admin", membership.RoleAdmin)
// Admin mints a single-use invite fixing handle + role.
inv, err := admin.CreateInvite("dora", membership.RoleMember, 0)
if err != nil {
t.Fatalf("admin CreateInvite: %v", err)
}
if len(inv.Token) != 64 || inv.ExpiresAt == "" {
t.Fatalf("invite malformed: %+v", inv)
}
if inv.Handle != "dora" || inv.Role != membership.RoleMember {
t.Fatalf("invite echo wrong: %+v", inv)
}
// It appears among the pending invites.
pend, err := admin.ListInvites()
if err != nil {
t.Fatalf("admin ListInvites: %v", err)
}
if !containsToken(pend, inv.Token) {
t.Fatalf("minted invite not pending: %+v", pend)
}
// A brand-new identity (NOT in the allowlist) redeems the invite via the
// UNSIGNED Register. We model its locally-generated keypair with a fresh
// identity and present its two public keys. Redeeming through this joiner
// client — which never registered and never seeded an admin — proves Register
// needs no admin signature; the bearer token is the sole authorization.
newID := mustIdentity(t)
signPub := hex.EncodeToString(newID.SignPub)
kexPub := hex.EncodeToString(newID.KexPub)
joiner, err := client.New(h.natsURL, h.ctrlURL, newID)
if err != nil {
t.Fatalf("connect joiner: %v", err)
}
defer joiner.Close()
if err := joiner.Register(inv.Token, signPub, kexPub); err != nil {
t.Fatalf("joiner Register: %v", err)
}
// Admin now sees dora in the allowlist with the invite's handle/role.
users, err := admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers: %v", err)
}
row, ok := findUserInfo(users, signPub)
if !ok {
t.Fatalf("registered dora missing from allowlist: %+v", users)
}
if row.Handle != "dora" || row.Role != membership.RoleMember || row.Status != membership.StatusActive {
t.Fatalf("dora row wrong: %+v", row)
}
// Single-use: redeeming again is an error.
if err := joiner.Register(inv.Token, signPub, kexPub); err == nil {
t.Fatalf("second Register should error (used token)")
}
// Admin hard-deletes dora; she vanishes from the allowlist entirely.
if err := admin.DeleteUser(signPub); err != nil {
t.Fatalf("admin DeleteUser: %v", err)
}
users, err = admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers after delete: %v", err)
}
if _, ok := findUserInfo(users, signPub); ok {
t.Fatalf("hard-deleted dora must NOT appear: %+v", users)
}
}
func containsToken(invites []client.InviteInfo, token string) bool {
for _, i := range invites {
if i.Token == token {
return true
}
}
return false
}