Files
YPMS/tests/test_stubs.py
Chris TonjesandClaude Opus 5.5 98b783b6b5 Phase 0 milestone 0.6: 501-stub routers for remaining entities
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>
2026-09-30 20:54:12 -04:00

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