26 stub entities (every matrix entity except Registrant, User, Role, AuditLog) get a router from framework/stub_router.py: one route per matrix action (List, Search, Create, View, Edit, Delete, Restore, VersionHistory). Each route runs the real RBAC check, writes an audit row, then returns 501. Source RFP C.5.7.27. - Registry in src/ypms/stubs.py; test fails if a matrix entity is neither real nor stubbed. - tests/test_stubs.py sweeps entity x role x route against the matrix JSON: 501 + exactly one audit row when granted, 403 + none when not. FERPA/CFSA stubs pinned closed to non-examiner roles. - Registrant restore now gated on the matrix's Restore action (was Edit, the vendored-factory convention); only the super-role holds it. - Test fixtures (roles, make_user, api) lifted to conftest; one app per session (route construction dominated runtime) and a shared password hash for minted-token users. 138 tests passing, ruff + mypy clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.8 KiB
Python
105 lines
3.8 KiB
Python
"""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
|