Files
unibus/pkg/membership/directory_test.go
T
egutierrez 2ba40701b2 feat(membership): add GET /api/directory for endpoint->handle resolution
Authenticated bus users (member or admin) can now map a sender's endpoint id
back to a readable handle. The endpoint is derived server-side from each user's
sign_pub with frame.EndpointID (base64url(sha256(signPub)), unpadded), matching
the bus's own construction byte-for-byte. Only active users are listed; under
enforce the existing auth middleware rejects an unauthenticated caller with 401.

Tests cover the golden path (two users -> 200 with handles + endpoints), the
auth contract (unsigned -> 401), revoked-user exclusion, and endpoint parity
against the cross-language vector from cmd/busvectors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:27:38 +02:00

154 lines
5.4 KiB
Go

package membership
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"testing"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/frame"
)
// directory signs a GET /api/directory as id and decodes the response envelope.
func directory(t *testing.T, h *authHarness, id cs.Identity, n int) (int, directoryResp) {
t.Helper()
code, body := signedJSON(t, h, "GET", "/api/directory", nil, id, n)
var resp directoryResp
if code == http.StatusOK {
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("decode directory: %v (%s)", err, body)
}
}
return code, resp
}
// findMember returns the directory entry for a signing key (case-insensitive).
func findMember(members []directoryMember, signPub string) (directoryMember, bool) {
want := normalizeSignPub(signPub)
for _, m := range members {
if normalizeSignPub(m.SignPub) == want {
return m, true
}
}
return directoryMember{}, false
}
// TestDirectoryGolden is the happy path: an authenticated bus user (here the seed
// admin alice, plus a registered member bob) reads the directory and gets every
// active user's handle, role, and an endpoint derived server-side from the
// sign_pub with the bus's own construction (frame.EndpointID). Two users in ->
// 200 with both handles and correct endpoints.
func TestDirectoryGolden(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob") // role member
bobPub := hex.EncodeToString(bob.SignPub)
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200 for an authenticated user, got %d", code)
}
aliceRow, ok := findMember(resp.Members, h.alicePub)
if !ok {
t.Fatalf("seed admin alice missing from directory: %+v", resp.Members)
}
if aliceRow.Handle != "alice" || aliceRow.Role != RoleAdmin {
t.Fatalf("alice row wrong: %+v", aliceRow)
}
if want := frame.EndpointID(h.alice.SignPub); aliceRow.Endpoint != want {
t.Fatalf("alice endpoint = %q, want %q", aliceRow.Endpoint, want)
}
bobRow, ok := findMember(resp.Members, bobPub)
if !ok {
t.Fatalf("registered member bob missing from directory: %+v", resp.Members)
}
if bobRow.Handle != "bob" || bobRow.Role != RoleMember {
t.Fatalf("bob row wrong: %+v", bobRow)
}
if want := frame.EndpointID(bob.SignPub); bobRow.Endpoint != want {
t.Fatalf("bob endpoint = %q, want %q", bobRow.Endpoint, want)
}
}
// TestDirectoryUnauthenticatedRejected is the auth contract: under enforce an
// unsigned GET /api/directory is rejected with 401 by the middleware, before the
// handler ever runs — the directory is not public.
func TestDirectoryUnauthenticatedRejected(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
req, _ := http.NewRequest("GET", h.ts.URL+"/api/directory", nil)
code, _ := do(t, req)
if code != http.StatusUnauthorized {
t.Fatalf("unsigned directory request under enforce should be 401, got %d", code)
}
}
// TestDirectoryExcludesRevoked: a revoked user must not appear in the directory
// (status=active filter), while active users still do.
func TestDirectoryExcludesRevoked(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
gone, _ := cs.GenerateIdentity()
register(t, h, gone, "gone")
gonePub := hex.EncodeToString(gone.SignPub)
if err := h.store.RevokeUser(gonePub); err != nil {
t.Fatalf("revoke gone: %v", err)
}
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200, got %d", code)
}
if _, ok := findMember(resp.Members, gonePub); ok {
t.Fatalf("revoked user must not appear in directory: %+v", resp.Members)
}
if _, ok := findMember(resp.Members, h.alicePub); !ok {
t.Fatalf("active admin alice should still appear: %+v", resp.Members)
}
}
// TestDirectoryEndpointParity pins the server-side endpoint derivation to the
// cross-language parity vector emitted by cmd/busvectors (and consumed by the
// uniweb crypto.ts endpointID test): for a FIXED sign_pub the directory must
// return the exact base64url(sha256(signPub)) endpoint, byte-for-byte. The
// expected value is recomputed here independently of frame.EndpointID so the test
// fails if the handler ever diverges from the canonical construction.
func TestDirectoryEndpointParity(t *testing.T) {
// Vector from cmd/busvectors (seed 000102..1f -> Ed25519 public key).
const vectorSignPub = "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
const vectorEndpoint = "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw"
// Independent recomputation: base64url(sha256(raw signPub bytes)), unpadded.
raw, err := hex.DecodeString(vectorSignPub)
if err != nil {
t.Fatalf("decode vector sign_pub: %v", err)
}
sum := sha256.Sum256(raw)
if got := base64.RawURLEncoding.EncodeToString(sum[:]); got != vectorEndpoint {
t.Fatalf("vector self-check: recomputed endpoint %q != pinned %q", got, vectorEndpoint)
}
h := newAuthHarness(t, AuthEnforce)
if err := h.store.AddUser(vectorSignPub, "vectorbot", RoleMember); err != nil {
t.Fatalf("add vector user: %v", err)
}
code, resp := directory(t, h, h.alice, 1)
if code != http.StatusOK {
t.Fatalf("directory should be 200, got %d", code)
}
row, ok := findMember(resp.Members, vectorSignPub)
if !ok {
t.Fatalf("vector user missing from directory: %+v", resp.Members)
}
if row.Endpoint != vectorEndpoint {
t.Fatalf("endpoint parity broken: directory returned %q, want %q", row.Endpoint, vectorEndpoint)
}
}