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 /directory as id and decodes the response envelope. The // path has no /api prefix: Caddy strips /api before forwarding to membershipd, so // the route is registered (and hit here) as /directory, matching production. func directory(t *testing.T, h *authHarness, id cs.Identity, n int) (int, directoryResp) { t.Helper() code, body := signedJSON(t, h, "GET", "/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 /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+"/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) } }