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