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>
105 lines
3.4 KiB
Go
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
|
|
}
|