"""Milestone 0.6 — every stub route enforces RBAC and audits before its 501. The permissions matrix JSON is the oracle: for every stub entity, every seeded role, and every stub route, the expected status is 501 if the role holds the action (or is the super-role) and 403 otherwise. Each 501 must leave exactly one audit row; each 403 must leave none. """ from __future__ import annotations from collections.abc import Callable from typing import Any import pytest from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.orm import Session from ypms.audit.models import AuditLog from ypms.framework.stub_router import STUB_ROUTES, STUB_STATUS from ypms.security.rbac import ADMIN_ROLE from ypms.seed.permissions import load_matrix from ypms.seed.roles import ROLES from ypms.stubs import STUB_ENTITIES MATRIX = load_matrix() REAL_ENTITIES = {"Registrant", "User", "Role", "AuditLog"} STUB_ID = 7 def _url(prefix: str, path: str) -> str: return prefix + path.replace("{id}", str(STUB_ID)) def test_every_matrix_entity_is_real_or_stubbed() -> None: assert set(STUB_ENTITIES) == set(MATRIX["entities"]) - REAL_ENTITIES def test_stub_routes_cover_every_matrix_action() -> None: assert {action for _, _, action in STUB_ROUTES} == set(MATRIX["actions"]) def test_stub_paths_are_unique() -> None: assert len(set(STUB_ENTITIES.values())) == len(STUB_ENTITIES) def test_stub_requires_authentication(api: TestClient) -> None: assert api.get("/guardians").status_code == 401 @pytest.mark.parametrize("entity", sorted(STUB_ENTITIES)) def test_stub_rbac_and_audit_follow_matrix( entity: str, api: TestClient, db: Session, make_user: Callable[..., tuple[Any, dict[str, str]]], ) -> None: prefix = STUB_ENTITIES[entity] for role in (r["code"] for r in ROLES): _, headers = make_user(f"u.{role.lower()}", role) granted = set(MATRIX["grants"].get(role, {}).get(entity, [])) for method, path, action in STUB_ROUTES: allowed = role == ADMIN_ROLE or action in granted before = db.execute(select(AuditLog.id)).all() r = api.request(method, _url(prefix, path), headers=headers) assert r.status_code == (501 if allowed else 403), (role, method, path) new = list( db.execute(select(AuditLog).where(AuditLog.id.notin_([b[0] for b in before]))).scalars() ) if not allowed: assert new == [], (role, action) continue body = r.json() assert body["status"] == "not_implemented" assert (body["entity"], body["action"]) == (entity, action) (row,) = new assert (row.entity, row.action) == (entity, action) assert row.actor_username == f"u.{role.lower()}" assert row.after == {"status": STUB_STATUS} assert row.entity_id == (STUB_ID if "{id}" in path else None) def test_search_not_captured_by_id_route( api: TestClient, make_user: Callable[..., tuple[Any, dict[str, str]]] ) -> None: _, admin = make_user("admin", ADMIN_ROLE) assert api.get("/programs/search", headers=admin).json()["action"] == "Search" @pytest.mark.parametrize("entity", ["EducationRecord", "FosterIndicator"]) @pytest.mark.parametrize("role", ["OYP_STAFF", "PAYROLL_ADMIN", "REPORTING_ANALYST", "HOST_USER"]) def test_ferpa_and_cfsa_stubs_closed_to_non_examiners( entity: str, role: str, api: TestClient, make_user: Callable[..., tuple[Any, dict[str, str]]], ) -> None: """Pinned separately from the matrix sweep: ARCH §6.2 guardrail.""" _, headers = make_user("u", role) prefix = STUB_ENTITIES[entity] for method, path, _ in STUB_ROUTES: assert api.request(method, _url(prefix, path), headers=headers).status_code == 403