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>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
)
|
||||
|
||||
// postRegister posts an UNSIGNED /register request (the wallet-model join: the
|
||||
// new identity is not yet in the allowlist, so it cannot sign). It returns the
|
||||
// status and body so a test can assert the precise code.
|
||||
func postRegister(t *testing.T, h *authHarness, body registerReq) (int, string) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal register: %v", err)
|
||||
}
|
||||
resp, err := http.Post(h.ts.URL+"/register", "application/json", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatalf("post register: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(rb)
|
||||
}
|
||||
|
||||
// TestInvitesHTTP_Golden is the end-to-end wallet-model flow over real HTTP:
|
||||
// alice (admin) mints an invite, a brand-new identity redeems it UNSIGNED via
|
||||
// /register, the user then appears in the admin allowlist, and a second redeem of
|
||||
// the same token is rejected as used.
|
||||
func TestInvitesHTTP_Golden(t *testing.T) {
|
||||
h := newAuthHarness(t, AuthEnforce)
|
||||
|
||||
// Admin mints an invite.
|
||||
var inv createInviteResp
|
||||
code, body := signedJSON(t, h, "POST", "/invites",
|
||||
createInviteReq{Handle: "newbie", Role: RoleMember}, h.alice, 1)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("admin create invite should be 201, got %d (%s)", code, body)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &inv); err != nil {
|
||||
t.Fatalf("decode invite: %v (%s)", err, body)
|
||||
}
|
||||
if len(inv.Token) != 64 || inv.ExpiresAt == "" {
|
||||
t.Fatalf("invite token/expiry malformed: %+v", inv)
|
||||
}
|
||||
|
||||
// A brand-new identity redeems it WITHOUT any admin signature.
|
||||
id, _ := cs.GenerateIdentity()
|
||||
signPub := hex.EncodeToString(id.SignPub)
|
||||
kexPub := hex.EncodeToString(id.KexPub)
|
||||
if code, body := postRegister(t, h, registerReq{Token: inv.Token, SignPub: signPub, KexPub: kexPub}); code != http.StatusCreated {
|
||||
t.Fatalf("register should be 201, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// The user now appears in the admin allowlist with the invite's handle/role.
|
||||
users := listUsers(t, h, 2)
|
||||
row, ok := findUser(users, signPub)
|
||||
if !ok {
|
||||
t.Fatalf("registered user missing from allowlist: %+v", users)
|
||||
}
|
||||
if row.Handle != "newbie" || row.Role != RoleMember || row.Status != StatusActive {
|
||||
t.Fatalf("registered user row wrong: %+v", row)
|
||||
}
|
||||
|
||||
// The invite is no longer pending.
|
||||
if code, body := signedJSON(t, h, "GET", "/invites", nil, h.alice, 3); code == http.StatusOK {
|
||||
var pend []inviteJSON
|
||||
_ = json.Unmarshal([]byte(body), &pend)
|
||||
for _, p := range pend {
|
||||
if p.Token == inv.Token {
|
||||
t.Fatalf("consumed invite should not be listed as pending: %+v", pend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-use: a second redeem of the same token is 409 used.
|
||||
id2, _ := cs.GenerateIdentity()
|
||||
if code, body := postRegister(t, h, registerReq{
|
||||
Token: inv.Token, SignPub: hex.EncodeToString(id2.SignPub), KexPub: hex.EncodeToString(id2.KexPub),
|
||||
}); code != http.StatusConflict {
|
||||
t.Fatalf("second redeem should be 409, got %d (%s)", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvitesHTTP_RegisterValidation covers /register input + state errors: an
|
||||
// unknown token is 404, an expired token is 410, and malformed hex keys are 400 —
|
||||
// each WITHOUT registering anything.
|
||||
func TestInvitesHTTP_RegisterValidation(t *testing.T) {
|
||||
h := newAuthHarness(t, AuthEnforce)
|
||||
id, _ := cs.GenerateIdentity()
|
||||
signPub := hex.EncodeToString(id.SignPub)
|
||||
kexPub := hex.EncodeToString(id.KexPub)
|
||||
|
||||
// Unknown token -> 404.
|
||||
if code, body := postRegister(t, h, registerReq{Token: "deadbeef", SignPub: signPub, KexPub: kexPub}); code != http.StatusNotFound {
|
||||
t.Fatalf("unknown token should be 404, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// Malformed sign_pub -> 400.
|
||||
if code, body := postRegister(t, h, registerReq{Token: "x", SignPub: "abcd", KexPub: kexPub}); code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// Malformed kex_pub -> 400.
|
||||
if code, body := postRegister(t, h, registerReq{Token: "x", SignPub: signPub, KexPub: "zzzz"}); code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed kex_pub should be 400, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// Expired token -> 410. Mint via the admin API, then force its deadline past
|
||||
// directly in the store (white-box).
|
||||
var inv createInviteResp
|
||||
_, body := signedJSON(t, h, "POST", "/invites", createInviteReq{Handle: "late", Role: RoleMember}, h.alice, 1)
|
||||
if err := json.Unmarshal([]byte(body), &inv); err != nil {
|
||||
t.Fatalf("decode invite: %v (%s)", err, body)
|
||||
}
|
||||
ss, ok := h.store.(*sqliteStore)
|
||||
if !ok {
|
||||
t.Fatalf("expected sqliteStore harness")
|
||||
}
|
||||
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
|
||||
if _, err := ss.db.Exec(`UPDATE invites SET expires_at = ? WHERE token = ?`, past, inv.Token); err != nil {
|
||||
t.Fatalf("force expire: %v", err)
|
||||
}
|
||||
if code, rb := postRegister(t, h, registerReq{Token: inv.Token, SignPub: signPub, KexPub: kexPub}); code != http.StatusGone {
|
||||
t.Fatalf("expired token should be 410, got %d (%s)", code, rb)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvitesHTTP_NonAdminForbidden is the security spine for the new endpoints:
|
||||
// a REGISTERED non-admin (bob) is denied on POST /invites, GET /invites,
|
||||
// DELETE /invites/{token}, and DELETE /users/{signpub} — each a 403 by role.
|
||||
func TestInvitesHTTP_NonAdminForbidden(t *testing.T) {
|
||||
h := newAuthHarness(t, AuthEnforce)
|
||||
|
||||
bob, _ := cs.GenerateIdentity()
|
||||
register(t, h, bob, "bob") // role member
|
||||
bobPub := hex.EncodeToString(bob.SignPub)
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{"create invite", "POST", "/invites", createInviteReq{Handle: "x", Role: RoleMember}},
|
||||
{"list invites", "GET", "/invites", nil},
|
||||
{"cancel invite", "DELETE", "/invites/sometoken", nil},
|
||||
{"delete user", "DELETE", "/users/" + bobPub, nil},
|
||||
}
|
||||
for i, c := range checks {
|
||||
code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsersHTTP_HardDelete proves DELETE /users/{signpub} purges a user (distinct
|
||||
// from revoke's status flip): alice adds carol, hard-deletes her, and carol then
|
||||
// vanishes from the allowlist entirely (not merely flagged revoked).
|
||||
func TestUsersHTTP_HardDelete(t *testing.T) {
|
||||
h := newAuthHarness(t, AuthEnforce)
|
||||
|
||||
carol, _ := cs.GenerateIdentity()
|
||||
carolPub := hex.EncodeToString(carol.SignPub)
|
||||
if code, body := signedJSON(t, h, "POST", "/users",
|
||||
addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated {
|
||||
t.Fatalf("add carol should be 201, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// Hard-delete carol.
|
||||
if code, body := signedJSON(t, h, "DELETE", "/users/"+carolPub, nil, h.alice, 2); code != http.StatusOK {
|
||||
t.Fatalf("hard-delete carol should be 200, got %d (%s)", code, body)
|
||||
}
|
||||
|
||||
// She is gone entirely — not present in the list at all (vs revoke, which
|
||||
// keeps her as status=revoked).
|
||||
users := listUsers(t, h, 3)
|
||||
if _, ok := findUser(users, carolPub); ok {
|
||||
t.Fatalf("hard-deleted carol must NOT appear in the allowlist: %+v", users)
|
||||
}
|
||||
|
||||
// Deleting her again is a 404.
|
||||
if code, body := signedJSON(t, h, "DELETE", "/users/"+carolPub, nil, h.alice, 4); code != http.StatusNotFound {
|
||||
t.Fatalf("re-delete should be 404, got %d (%s)", code, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user