using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace BigRegister.Tests; /// RB-12/BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the /// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing /// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints /// with no gate at all) are exactly the failure mode this test is a safety net for — and it is /// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in /// being hard to fool. /// /// Every mapped route must be accounted for exactly one of two ways: /// - it carries an marker (.Gate("XAdmin"), added at the /// call site in Program.cs) naming one of the five admin authz wrappers, or /// - it is named, with a reason, in below. /// /// The allow-list is deliberately not "public routes" — most of its entries are NOT public. /// `GET /aanvragen/{id}` requires a caller identity and is scoped to that caller's own BSN /// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the /// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point /// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down") /// generalised to every route that isn't wrapper-gated: the reviewer reads a name and a reason, /// not silence. public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixture { // Not TestWebApplicationFactory's HttpClient — this never issues a request, only reads the // route table off the host's DI container. Uses the shared per-class isolated db file (see // TestWebApplicationFactory's own doc comment) rather than a bare `new // WebApplicationFactory()`, which would share the mutable static Db.ConnectionString // with whatever other test class last set it and race "table already exists" against it. private TestWebApplicationFactory Factory { get; } = factory; private sealed record AllowListEntry(string Method, string Pattern, string Reason); private static readonly AllowListEntry[] AllowList = [ // --- Orchestrator probes: no data, no PII, run before any identity concern applies. --- new("GET", "/health", "Liveness probe for orchestrators."), new("GET", "/health/ready", "Readiness probe for orchestrators."), // --- Static/reference demo data (SeedData & friends): identical for every caller in // this POC (one seeded citizen), nothing to scope by. --- new("GET", "/api/v1/dashboard-view", "Static reference data (SeedData) — same for every caller in this POC."), new("GET", "/api/v1/notes", "Static reference data (SeedData.Notes) — same for every caller in this POC."), new("GET", "/api/v1/brp/address", "Static BRP reference fixture — same for every caller in this POC."), new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."), new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."), new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."), new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design (WP-47) — only the PUT toggle is admin-gated."), new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."), // --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()), // not a role-only admin wrapper because the boundary is resource ownership, not a role. --- new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."), new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."), new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), new("GET", "/api/v1/aanvragen", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), new("GET", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), new("POST", "/api/v1/aanvragen", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), new("PUT", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), new("DELETE", "/api/v1/aanvragen/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), new("POST", "/api/v1/aanvragen/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), // --- External caller, not a Principal at all. --- new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."), // --- Brief (letter composition): PRD-0002's own status-machine enforcement is the // enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's // Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers, // not a missing one. --- new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."), new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."), new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."), new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."), new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."), new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."), new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."), new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."), new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."), ]; private static readonly HashSet KnownWrappers = ["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"]; private static IEnumerable RealRoutes(EndpointDataSource source) => source.Endpoints.OfType() // MapGroup's own catch-all/description endpoints carry no HTTP method — not a route // an HTTP client can actually hit distinctly, so not this test's concern. .Where(e => e.Metadata.GetMetadata() is not null); private static string Key(string method, string pattern) => $"{method} {pattern}"; [Fact] public void Every_mapped_route_is_authz_gated_or_on_the_named_allow_list() { var source = Factory.Services.GetRequiredService(); var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern)); var seenAllowListKeys = new HashSet(); var unaccounted = new List(); foreach (var route in RealRoutes(source)) { var pattern = route.RoutePattern.RawText!; foreach (var method in route.Metadata.GetMetadata()!.HttpMethods) { var key = Key(method, pattern); var gated = route.Metadata.GetMetadata() is { } gate && KnownWrappers.Contains(gate.Wrapper); var listed = allowed.ContainsKey(key); if (listed) seenAllowListKeys.Add(key); if (!gated && !listed) unaccounted.Add(key); } } Assert.True(unaccounted.Count == 0, "Route(s) with no authz gate and no allow-list entry — either add `.Gate(\"XAdmin\")` " + "at the mapping site, or add a named, reasoned entry to RouteInventoryTests.AllowList:\n" + string.Join("\n", unaccounted)); // The allow-list is a decision log, not a wishlist — an entry for a route that no longer // exists (renamed, removed) is exactly the kind of drift this test exists to catch. var stale = allowed.Keys.Except(seenAllowListKeys).ToList(); Assert.True(stale.Count == 0, "Allow-list entry with no matching live route (stale — the route was renamed or " + "removed):\n" + string.Join("\n", stale)); } /// Every `.Gate(...)` call must name one of the five known wrappers — a typo here would /// silently fall back to "unaccounted for" above, but pinning it down explicitly gives a /// clearer failure than the generic route-mismatch message. [Fact] public void Every_gate_marker_names_a_known_admin_wrapper() { var source = Factory.Services.GetRequiredService(); var unknown = RealRoutes(source) .Select(r => r.Metadata.GetMetadata()) .Where(g => g is not null) .Select(g => g!.Wrapper) .Where(w => !KnownWrappers.Contains(w)) .Distinct() .ToList(); Assert.True(unknown.Count == 0, "Unknown wrapper name(s) in a .Gate(...) call: " + string.Join(", ", unknown)); } }