package membership import ( "encoding/hex" "encoding/json" "net/http" "testing" "time" cs "fn-registry/functions/cybersecurity" ) // signedJSON is signedReq for a JSON body: it marshals v and signs the request // as id with a distinct nonce. It returns the response status and body, reusing // the auth_test harness so these tests exercise the real signed wire contract. func signedJSON(t *testing.T, h *authHarness, method, path string, v any, id cs.Identity, n int) (int, string) { t.Helper() var body []byte if v != nil { b, err := json.Marshal(v) if err != nil { t.Fatalf("marshal body: %v", err) } body = b } return do(t, signedReq(t, h.ts.URL, method, path, body, id, time.Now().Unix(), nonceN(n))) } // TestUsersHTTP_NonAdminForbidden is the security spine: a REGISTERED but // non-admin signer (bob, role member) is denied on every user-management // endpoint. His signature clears auth (he is in the allowlist), so each request // reaches the handler, where requireAdmin returns 403 — default-deny by role. func TestUsersHTTP_NonAdminForbidden(t *testing.T) { h := newAuthHarness(t, AuthEnforce) bob, _ := cs.GenerateIdentity() register(t, h, bob, "bob") // role member (see register in authz_test.go) bobPub := hex.EncodeToString(bob.SignPub) victim, _ := cs.GenerateIdentity() victimPub := hex.EncodeToString(victim.SignPub) checks := []struct { name string method string path string body any }{ {"list users", "GET", "/users", nil}, {"add user", "POST", "/users", addUserReq{SignPub: victimPub, Handle: "mallory", Role: RoleMember}}, {"revoke user", "POST", "/users/" + bobPub + "/revoke", 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_AdminRoundtrip exercises the golden path end to end: alice (the // seeded admin) adds carol, sees her in the list as active, revokes her, then // sees her status flip to revoked (no hard delete — she stays in the list). func TestUsersHTTP_AdminRoundtrip(t *testing.T) { h := newAuthHarness(t, AuthEnforce) carol, _ := cs.GenerateIdentity() carolPub := hex.EncodeToString(carol.SignPub) // Add carol as a member. if code, body := signedJSON(t, h, "POST", "/users", addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated { t.Fatalf("admin add carol should be 201, got %d (%s)", code, body) } // List: carol present and active; alice (the seed admin) also present. users := listUsers(t, h, 2) carolRow, ok := findUser(users, carolPub) if !ok { t.Fatalf("carol missing from list after add: %+v", users) } if carolRow.Status != StatusActive || carolRow.Role != RoleMember || carolRow.Handle != "carol" { t.Fatalf("carol row wrong after add: %+v", carolRow) } if _, ok := findUser(users, h.alicePub); !ok { t.Fatalf("seeded admin alice missing from list: %+v", users) } // Revoke carol. if code, body := signedJSON(t, h, "POST", "/users/"+carolPub+"/revoke", nil, h.alice, 3); code != http.StatusOK { t.Fatalf("admin revoke carol should be 200, got %d (%s)", code, body) } // List again: carol still present, now revoked (status flip, not delete). users = listUsers(t, h, 4) carolRow, ok = findUser(users, carolPub) if !ok { t.Fatalf("carol vanished from list after revoke (should be a status flip): %+v", users) } if carolRow.Status != StatusRevoked { t.Fatalf("carol should be revoked, got status %q", carolRow.Status) } } // TestUsersHTTP_Validation covers the input-validation contract: a malformed hex // key is 400, an unknown role is 400, and re-adding an already-registered key is // 409 (the existing row is left untouched — no silent upsert). func TestUsersHTTP_Validation(t *testing.T) { h := newAuthHarness(t, AuthEnforce) good, _ := cs.GenerateIdentity() goodPub := hex.EncodeToString(good.SignPub) // Invalid hex (too short) -> 400. if code, body := signedJSON(t, h, "POST", "/users", addUserReq{SignPub: "abcd", Handle: "shorty", Role: RoleMember}, h.alice, 1); code != http.StatusBadRequest { t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body) } // Invalid role -> 400. if code, body := signedJSON(t, h, "POST", "/users", addUserReq{SignPub: goodPub, Handle: "weirdrole", Role: "superuser"}, h.alice, 2); code != http.StatusBadRequest { t.Fatalf("invalid role should be 400, got %d (%s)", code, body) } // Re-adding the seeded admin's own key -> 409 (idempotency, no overwrite). if code, body := signedJSON(t, h, "POST", "/users", addUserReq{SignPub: h.alicePub, Handle: "alice-again", Role: RoleMember}, h.alice, 3); code != http.StatusConflict { t.Fatalf("re-adding an existing key should be 409, got %d (%s)", code, body) } // And the existing row is untouched: alice is still an active admin. u, err := h.store.GetUser(h.alicePub) if err != nil { t.Fatalf("get alice after conflicting re-add: %v", err) } if u.Role != RoleAdmin || u.Status != StatusActive || u.Handle != "alice" { t.Fatalf("conflicting re-add mutated the existing row: %+v", u) } } // listUsers signs a GET /users as alice and decodes the response. func listUsers(t *testing.T, h *authHarness, n int) []userJSON { t.Helper() code, body := signedJSON(t, h, "GET", "/users", nil, h.alice, n) if code != http.StatusOK { t.Fatalf("admin list users should be 200, got %d (%s)", code, body) } var users []userJSON if err := json.Unmarshal([]byte(body), &users); err != nil { t.Fatalf("decode users: %v (%s)", err, body) } return users } // findUser returns the row with the given signing key (case-insensitive). func findUser(users []userJSON, signPub string) (userJSON, bool) { want := normalizeSignPub(signPub) for _, u := range users { if normalizeSignPub(u.SignPub) == want { return u, true } } return userJSON{}, false }