From 52c80ac0100093cccf193da41d27af849c021ab9 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 22:14:44 +0200 Subject: [PATCH] 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) --- pkg/client/invites_test.go | 104 +++++++++++++++ pkg/membership/invites_http_test.go | 194 ++++++++++++++++++++++++++++ pkg/membership/invites_test.go | 186 ++++++++++++++++++++++++++ 3 files changed, 484 insertions(+) create mode 100644 pkg/client/invites_test.go create mode 100644 pkg/membership/invites_http_test.go create mode 100644 pkg/membership/invites_test.go diff --git a/pkg/client/invites_test.go b/pkg/client/invites_test.go new file mode 100644 index 00000000..4466733d --- /dev/null +++ b/pkg/client/invites_test.go @@ -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 +} diff --git a/pkg/membership/invites_http_test.go b/pkg/membership/invites_http_test.go new file mode 100644 index 00000000..7025001b --- /dev/null +++ b/pkg/membership/invites_http_test.go @@ -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) + } +} diff --git a/pkg/membership/invites_test.go b/pkg/membership/invites_test.go new file mode 100644 index 00000000..85b4b06e --- /dev/null +++ b/pkg/membership/invites_test.go @@ -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) + } + }) + } +}