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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user