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>
195 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|