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>
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""Registrant vertical slice, end-to-end through the FastAPI TestClient.
|
|
|
|
Runs against the real seeded roles + permissions matrix (not hand-built
|
|
grants), so these tests also pin the matrix's Registrant rows.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from datetime import date
|
|
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.crud import REDACTED
|
|
from ypms.framework.pii_audit import PII_READ_ACTION
|
|
from ypms.framework.version_history import VersionHistory
|
|
from ypms.users.models import User
|
|
from ypms.youth.models import Registrant
|
|
|
|
SSN = "123-45-6789"
|
|
PAYLOAD: dict[str, Any] = {
|
|
"first_name": "Jordan",
|
|
"last_name": "Rivera",
|
|
"dob": "2010-06-15",
|
|
"ssn": SSN,
|
|
"email": "jordan@example.org",
|
|
"city": "Washington",
|
|
"state": "DC",
|
|
"zip": "20001",
|
|
}
|
|
|
|
Headers = dict[str, str]
|
|
|
|
|
|
@pytest.fixture
|
|
def staff(make_user: Callable[..., tuple[User, Headers]]) -> tuple[User, Headers]:
|
|
return make_user("oyp.staff", "OYP_STAFF")
|
|
|
|
|
|
def _create(api: TestClient, headers: Headers, **overrides: Any) -> dict[str, Any]:
|
|
r = api.post("/registrants", json={**PAYLOAD, **overrides}, headers=headers)
|
|
assert r.status_code == 201, r.text
|
|
body: dict[str, Any] = r.json()
|
|
return body
|
|
|
|
|
|
def _audit(db: Session, action: str) -> list[AuditLog]:
|
|
stmt = select(AuditLog).where(AuditLog.entity == "Registrant", AuditLog.action == action)
|
|
return list(db.execute(stmt).scalars())
|
|
|
|
|
|
# ---------------- create + masking ----------------
|
|
|
|
|
|
def test_create_masks_ssn_and_never_returns_it(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
body = _create(api, staff[1])
|
|
assert body["ssn_masked"] == "XXX-XX-6789"
|
|
assert "ssn" not in body
|
|
assert "ssn_encrypted" not in body
|
|
assert "ssn_last4" not in body
|
|
assert "6789" not in str({k: v for k, v in body.items() if k != "ssn_masked"})
|
|
|
|
row = db.get(Registrant, body["id"])
|
|
assert row is not None
|
|
assert row.ssn_encrypted and "123456789" not in row.ssn_encrypted
|
|
assert row.decrypt_ssn() == "123456789"
|
|
|
|
|
|
def test_create_emits_redacted_audit_and_version_snapshot(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
body = _create(api, staff[1])
|
|
(row,) = _audit(db, "create")
|
|
assert row.actor_username == "oyp.staff"
|
|
assert row.entity_id == body["id"]
|
|
assert row.after is not None
|
|
assert row.after["ssn_encrypted"] == REDACTED
|
|
assert row.after["ssn_last4"] == "6789"
|
|
assert row.after["dob"] == "2010-06-15"
|
|
|
|
(snap,) = db.execute(select(VersionHistory)).scalars()
|
|
assert snap.snapshot["ssn_encrypted"] == REDACTED
|
|
|
|
|
|
def test_minor_flag_derived_from_dob(api: TestClient, staff: tuple[User, Headers]) -> None:
|
|
minor = _create(api, staff[1], dob=date.today().replace(year=date.today().year - 16).isoformat())
|
|
adult = _create(api, staff[1], dob="1990-01-01", ssn=None)
|
|
assert minor["minor_flag"] is True
|
|
assert adult["minor_flag"] is False
|
|
assert adult["ssn_masked"] is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad",
|
|
[
|
|
{"ssn": "12345678"},
|
|
{"ssn": "000-12-3456"},
|
|
{"ssn": "666-12-3456"},
|
|
{"ssn": "900-12-3456"},
|
|
{"ssn": "123-00-4567"},
|
|
{"ssn": "123-45-0000"},
|
|
{"dob": "2999-01-01"},
|
|
{"zip": "2000"},
|
|
{"email": "not-an-email"},
|
|
],
|
|
)
|
|
def test_create_validation(api: TestClient, staff: tuple[User, Headers], bad: dict[str, str]) -> None:
|
|
r = api.post("/registrants", json={**PAYLOAD, **bad}, headers=staff[1])
|
|
assert r.status_code == 422
|
|
|
|
|
|
# ---------------- reads emit PII audit ----------------
|
|
|
|
|
|
def test_get_emits_pii_read(api: TestClient, db: Session, staff: tuple[User, Headers]) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
r = api.get(f"/registrants/{rid}", headers=staff[1])
|
|
assert r.status_code == 200
|
|
assert r.json()["ssn_masked"] == "XXX-XX-6789"
|
|
(row,) = _audit(db, PII_READ_ACTION)
|
|
assert row.entity_id == rid
|
|
assert row.actor_username == "oyp.staff"
|
|
assert row.ip is not None
|
|
|
|
|
|
def test_list_emits_one_pii_read_with_ids(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
ids = [_create(api, staff[1])["id"] for _ in range(3)]
|
|
r = api.get("/registrants?limit=2", headers=staff[1])
|
|
assert r.status_code == 200
|
|
page = r.json()
|
|
assert [i["id"] for i in page["items"]] == ids[:2]
|
|
assert page["next_cursor"] == ids[1]
|
|
(row,) = _audit(db, PII_READ_ACTION)
|
|
assert row.entity_id is None
|
|
assert row.after == {"ids": ids[:2], "count": 2}
|
|
|
|
|
|
def test_get_missing_is_404_and_not_audited(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
assert api.get("/registrants/999", headers=staff[1]).status_code == 404
|
|
assert _audit(db, PII_READ_ACTION) == []
|
|
|
|
|
|
def test_unauthenticated_is_401(api: TestClient) -> None:
|
|
assert api.get("/registrants").status_code == 401
|
|
|
|
|
|
# ---------------- update / delete / restore / history ----------------
|
|
|
|
|
|
def test_update_bumps_version_and_audits(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
r = api.patch(f"/registrants/{rid}", json={"ssn": "234-56-4321", "city": "DC"}, headers=staff[1])
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["version"] == 2
|
|
assert body["ssn_masked"] == "XXX-XX-4321"
|
|
(row,) = _audit(db, "update")
|
|
assert row.before is not None and row.after is not None
|
|
assert row.before["ssn_last4"] == "6789"
|
|
assert row.after["ssn_last4"] == "4321"
|
|
assert row.before["ssn_encrypted"] == row.after["ssn_encrypted"] == REDACTED
|
|
|
|
|
|
def test_update_rejects_null_on_required_field(
|
|
api: TestClient, staff: tuple[User, Headers]
|
|
) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
r = api.patch(f"/registrants/{rid}", json={"last_name": None}, headers=staff[1])
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_update_cannot_set_user_id(
|
|
api: TestClient, db: Session, staff: tuple[User, Headers]
|
|
) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
api.patch(f"/registrants/{rid}", json={"user_id": staff[0].id}, headers=staff[1])
|
|
row = db.get(Registrant, rid)
|
|
assert row is not None and row.user_id is None
|
|
|
|
|
|
def test_delete_requires_delete_permission_then_restore(
|
|
api: TestClient,
|
|
db: Session,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
# OYP_STAFF has no Delete on Registrant in the matrix.
|
|
assert api.delete(f"/registrants/{rid}", headers=staff[1]).status_code == 403
|
|
|
|
_, admin = make_user("admin", "DOES_SYSTEM_ADMIN")
|
|
r = api.delete(f"/registrants/{rid}", headers=admin)
|
|
assert r.status_code == 200 and r.json()["deleted_at"] is not None
|
|
assert api.get(f"/registrants/{rid}", headers=staff[1]).status_code == 404
|
|
|
|
# Restore is its own matrix action; only the super-role holds it today.
|
|
assert api.post(f"/registrants/{rid}/restore", headers=staff[1]).status_code == 403
|
|
r = api.post(f"/registrants/{rid}/restore", headers=admin)
|
|
assert r.status_code == 200 and r.json()["deleted_at"] is None
|
|
assert [a.action for a in db.execute(select(AuditLog).order_by(AuditLog.id)).scalars()][-2:] == [
|
|
"delete",
|
|
"restore",
|
|
]
|
|
|
|
|
|
def test_delete_missing_is_404(
|
|
api: TestClient, make_user: Callable[..., tuple[User, Headers]]
|
|
) -> None:
|
|
_, admin = make_user("admin", "DOES_SYSTEM_ADMIN")
|
|
assert api.delete("/registrants/999", headers=admin).status_code == 404
|
|
assert api.post("/registrants/999/restore", headers=admin).status_code == 404
|
|
|
|
|
|
def test_history_requires_version_history_permission(
|
|
api: TestClient,
|
|
db: Session,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
rid = _create(api, staff[1])["id"]
|
|
api.patch(f"/registrants/{rid}", json={"city": "DC"}, headers=staff[1])
|
|
|
|
# PAYROLL_ADMIN has View but not VersionHistory.
|
|
_, payroll = make_user("payroll", "PAYROLL_ADMIN")
|
|
assert api.get(f"/registrants/{rid}", headers=payroll).status_code == 200
|
|
assert api.get(f"/registrants/{rid}/history", headers=payroll).status_code == 403
|
|
|
|
r = api.get(f"/registrants/{rid}/history", headers=staff[1])
|
|
assert r.status_code == 200
|
|
assert [h["version"] for h in r.json()] == [1, 2]
|
|
assert all(h["snapshot"]["ssn_encrypted"] == REDACTED for h in r.json())
|
|
assert api.get("/registrants/999/history", headers=staff[1]).status_code == 404
|
|
reads = [a for a in _audit(db, PII_READ_ACTION) if a.actor_username == "oyp.staff"]
|
|
assert len(reads) == 1
|
|
|
|
|
|
# ---------------- portal-role row scoping ----------------
|
|
|
|
|
|
def test_youth_can_read_and_edit_only_own_row(
|
|
api: TestClient,
|
|
db: Session,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
youth, yh = make_user("youth", "YOUTH_USER")
|
|
mine = _create(api, staff[1])["id"]
|
|
theirs = _create(api, staff[1])["id"]
|
|
row = db.get(Registrant, mine)
|
|
assert row is not None
|
|
row.user_id = youth.id
|
|
db.flush()
|
|
|
|
assert api.get(f"/registrants/{mine}", headers=yh).status_code == 200
|
|
assert api.get(f"/registrants/{theirs}", headers=yh).status_code == 404
|
|
assert api.patch(f"/registrants/{mine}", json={"phone": "202-555-0100"}, headers=yh).status_code == 200
|
|
assert api.patch(f"/registrants/{theirs}", json={"phone": "x"}, headers=yh).status_code == 404
|
|
|
|
|
|
def test_youth_blocked_from_staff_routes(
|
|
api: TestClient,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
_, yh = make_user("youth", "YOUTH_USER")
|
|
rid = _create(api, staff[1])["id"]
|
|
assert api.get("/registrants", headers=yh).status_code == 403
|
|
assert api.post("/registrants", json=PAYLOAD, headers=yh).status_code == 403
|
|
assert api.post(f"/registrants/{rid}/restore", headers=yh).status_code == 403
|
|
assert api.get(f"/registrants/{rid}/history", headers=yh).status_code == 403
|
|
|
|
|
|
def test_parent_sees_nothing_until_guardian_link(
|
|
api: TestClient,
|
|
db: Session,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
parent, ph = make_user("parent", "PARENT_USER")
|
|
rid = _create(api, staff[1])["id"]
|
|
row = db.get(Registrant, rid)
|
|
assert row is not None
|
|
row.user_id = parent.id # even a (bogus) direct link grants nothing
|
|
db.flush()
|
|
assert api.get(f"/registrants/{rid}", headers=ph).status_code == 404
|
|
|
|
|
|
def test_staff_role_plus_portal_role_is_staff(
|
|
api: TestClient,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
_, h = make_user("dual", "OYP_STAFF", "YOUTH_USER")
|
|
rid = _create(api, staff[1])["id"]
|
|
assert api.get(f"/registrants/{rid}", headers=h).status_code == 200
|
|
assert api.get("/registrants", headers=h).status_code == 200
|
|
|
|
|
|
def test_host_user_denied(
|
|
api: TestClient,
|
|
staff: tuple[User, Headers],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
_, hh = make_user("host", "HOST_USER")
|
|
rid = _create(api, staff[1])["id"]
|
|
assert api.get(f"/registrants/{rid}", headers=hh).status_code == 403
|
|
|
|
|
|
def test_staff_guard_holds_even_if_matrix_grants_portal_role_list(
|
|
api: TestClient,
|
|
db: Session,
|
|
roles: dict[str, Any],
|
|
make_user: Callable[..., tuple[User, Headers]],
|
|
) -> None:
|
|
"""Defense in depth: a future matrix edit granting YOUTH_USER List must not
|
|
expose every registrant."""
|
|
from ypms.security.models import Permission
|
|
|
|
db.add(Permission(role_id=roles["YOUTH_USER"].id, entity="Registrant", action="List"))
|
|
db.flush()
|
|
_, yh = make_user("youth", "YOUTH_USER")
|
|
r = api.get("/registrants", headers=yh)
|
|
assert r.status_code == 403
|
|
assert r.json()["detail"]["reason"] == "staff-only route"
|