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

195 lines
7.2 KiB
Go

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)
}
}