Files
YPMS/tests/youth/test_registrant_api.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

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"