"""Every route on the bounty router refuses a plain employee. Written during the whole-feature review, which found the role gate *present* on all twelve new endpoints and *tested* on only some. Two of the untested ones — `/analytics/matrix` and `/analytics/lane` — return report titles, assignee names and reward amounts, so a dropped `_require_view` there publishes the security desk's working set to ~500 employees. Per-endpoint 403 tests cannot close that gap, because the gap was created by *adding* endpoints. So this enumerates the router instead of a list somebody has to remember to extend. The employee-facing bounty surface is a separate router (`bounty_self_router`), so every route on this one is admin-only by construction and there is no allowlist to argue about. The payloads are synthesised from each route's own Pydantic model rather than sent as `{}`. That matters: FastAPI validates the body *before* the handler runs, so 22 of these answer 422 to an employee and the gate never executes — an empty-body sweep would have been green while proving nothing about them. (The 422-before-403 ordering is the framework's uniform behaviour across every domain here and leaks no domain data, so it is recorded rather than "fixed" in one domain.) """ from __future__ import annotations import re from typing import Any import pytest from app.domains.bounty import constants as const from app.domains.bounty.router import router as bounty_router from app.domains.system_registry.router import router as registry_router pytestmark = pytest.mark.asyncio #: Syntactically valid, certainly absent. The gates run before any lookup, #: so a correctly-gated route answers 403 and never reaches the 404. _ABSENT = "00000000-0000-4000-8000-000000000000" #: Fields whose Pydantic validator checks a domain vocabulary, so an #: arbitrary string 422s before the gate is reached. Read off the constants #: themselves, so a renamed status breaks the import rather than quietly #: dropping a route from the sweep's coverage. _DOMAIN_VALUE: dict[str, Any] = { "status": sorted(const.STATUSES)[0], "remediation_status": sorted(const.REMEDIATION_STATUSES)[0], "payment_status": sorted(const.PAYMENT_STATUSES)[0], "budget_track": sorted(const.BUDGET_TRACKS)[0], "decision": sorted(const.OWNER_DECISIONS)[0], } def _from_schema(schema: dict[str, Any], defs: dict[str, Any]) -> Any: """A value that satisfies this JSON schema. It only has to pass validation — every handler refuses before looking at it.""" if "$ref" in schema: return _from_schema( defs.get(schema["$ref"].rsplit("/", 1)[-1], {}), defs, ) for key in ("anyOf", "oneOf"): for option in schema.get(key, []): if option.get("type") != "null": return _from_schema(option, defs) if "enum" in schema and schema["enum"]: return schema["enum"][0] kind = schema.get("type") if kind == "array": item = schema.get("items", {}) return [ _from_schema(item, defs) for _ in range(max(1, int(schema.get("minItems") or 1))) ] if kind == "object": return { name: _from_schema(sub, defs) for name, sub in (schema.get("properties") or {}).items() if name in (schema.get("required") or []) } if kind == "boolean": return False if kind in ("integer", "number"): return 1 fmt = schema.get("format") if fmt == "uuid": return _ABSENT if fmt == "date": return "2026-01-01" if fmt == "date-time": return "2026-01-01T00:00:00Z" if fmt == "email": return "probe@example.com" if fmt == "decimal": return "1" # Strings: honour minLength, and make anything email-shaped an address — # `researcher_email` has no `format` but a length floor plus a service # check, and "xxx" would 422 before the gate ran. floor = int(schema.get("minLength") or 1) title = (schema.get("title") or "").lower() if "email" in title: return "probe@example.com" return "x" * max(floor, 1) def _body_for_model(model: Any) -> dict[str, Any]: schema = model.model_json_schema() defs = schema.get("$defs") or {} required = schema.get("required") or [] out: dict[str, Any] = {} for name in required: if name in _DOMAIN_VALUE: out[name] = _DOMAIN_VALUE[name] continue out[name] = _from_schema( (schema.get("properties") or {}).get(name, {}), defs, ) return out def _query_for(route: Any) -> dict[str, Any]: """Required query params, read off the route rather than listed here — a new one must not be able to turn a covered route into a silent 422.""" out: dict[str, Any] = {} dependant = getattr(route, "dependant", None) for field in getattr(dependant, "query_params", None) or []: if not field.field_info.is_required(): continue name = field.alias or field.name if name in _DOMAIN_VALUE: out[name] = _DOMAIN_VALUE[name] continue # Honour a length floor: `/systems/suggest` declares min_length=2, # and a one-character filler 422s before the gate is reached. floor = 1 for constraint in getattr(field.field_info, "metadata", None) or []: floor = max(floor, int(getattr(constraint, "min_length", 0) or 0)) out[name] = "x" * floor return out def _routes(router: Any) -> list[tuple[str, str, dict[str, Any] | None, dict[str, Any]]]: out: list[tuple[str, str, dict[str, Any] | None, dict[str, Any]]] = [] for route in router.routes: path = getattr(route, "path", None) methods = getattr(route, "methods", None) if not path or not methods: continue body_field = getattr(route, "body_field", None) body: dict[str, Any] | None = None if body_field is not None: model = getattr(body_field.field_info, "annotation", None) if model is not None and hasattr(model, "model_json_schema"): body = _body_for_model(model) for method in sorted(methods): if method in {"HEAD", "OPTIONS"}: continue out.append((method, path, body, _query_for(route))) return out async def _sweep(client, headers, router: Any, *, minimum: int, methods: set[str] | None = None): routes = _routes(router) assert len(routes) >= minimum, ( f"only {len(routes)} routes enumerated — the sweep would pass while " "covering almost nothing" ) leaked: list[str] = [] unvalidated: list[str] = [] for method, path, body, query in routes: if methods is not None and method not in methods: continue resp = await client.request( method, re.sub(r"\{[^}]+\}", _ABSENT, path), headers=headers, params=query or None, json=body if body is not None else {}, ) if resp.status_code == 403: continue if resp.status_code == 422: # Payload synthesis missed a constraint. Not a leak (422 carries # no domain data) but it means this route is UNPROVEN, so say so # instead of counting it as covered. unvalidated.append(f"{method} {path}") continue leaked.append(f"{method} {path} -> {resp.status_code}") assert not leaked, ( "these routes did not refuse the caller:\n " + "\n ".join(leaked) ) assert not unvalidated, ( "these routes 422'd before reaching their gate, so this sweep does " "NOT prove they are gated — extend the payload synthesiser:\n " + "\n ".join(unvalidated) ) async def test_every_bounty_route_refuses_a_plain_employee( client, employee_headers, ): await _sweep(client, employee_headers, bounty_router, minimum=45) async def test_every_system_registry_route_refuses_a_plain_employee( client, employee_headers, ): """Same guard for the registry. Its employee-facing surfaces are two separate routers (`self_router`, `team_router`), so — as with bounty — every route on this one is admin-only by construction.""" await _sweep(client, employee_headers, registry_router, minimum=11) async def test_no_write_on_the_register_is_open_to_the_security_lane( client, auditor_headers, ): """"The security desk consumes ownership to route a finding, it does not decide who owns a system. IT maintains the register." — the platform owner's call, 2026-08-25. A single test on `POST /systems` was the only thing holding that line, and the write it left uncovered is `transfer_owner` — the one the whole design turns on, and the only way to change an owner at all. So this sweeps every non-GET route on the register instead. GETs are deliberately not swept: the security lane is *supposed* to read this, which is the entire reason it is one role away from writing it. """ await _sweep( client, auditor_headers, registry_router, minimum=4, methods={"POST", "PATCH", "PUT", "DELETE"}, ) async def test_the_sweep_would_notice_an_ungated_route( client, employee_headers, ): """The sweep is only worth its runtime if a missing gate makes it fail. A route that is ungated by construction must NOT come back 403 — if it does, the fixture is not a plain employee and the sweep above is vacuous.""" from fastapi import APIRouter from tests.support.router_probe import mounted_router probe = APIRouter(prefix="/api/v1/bounty-gate-probe") @probe.get("/open") async def _open() -> dict[str, str]: # pragma: no cover - probe only return {"leaked": "yes"} app = client._transport.app # type: ignore[attr-defined] with mounted_router(app, probe): resp = await client.get( "/api/v1/bounty-gate-probe/open", headers=employee_headers, ) assert resp.status_code == 200, ( "an ungated route must answer a plain employee; got " f"{resp.status_code}, so `employee_headers` is not a plain " "employee and the sweep above proves nothing" )