Files
YPMS/tests/youth/test_registrant_api.py
T
Chris TonjesandClaude Opus 5.5 5dd828f0e5 Phase 0 milestone 0.5: Registrant vertical slice
Registrant model + schemas + router + migration 0003, exercising the
vendored framework end-to-end (CRUDService, RBAC, audit, version history).

- SSN app-layer encrypted (AES-256-GCM, HKDF key, column-bound AAD,
  versioned format) in security/column_crypto.py; write-only on the model;
  serializer exposes only ssn_masked (XXX-XX-1234). C.5.7.50 / SC-28.
- pii_read audit on GET, list (one row per page with ids), and history.
  C.5.6.9 #5 / AU-14.
- Portal-only callers (YOUTH/PARENT/HOST) limited to own row via
  Registrant.user_id; out-of-scope is 404. PARENT_USER sees nothing until
  Guardian lands. History gated on VersionHistory, not View.
- minor_flag derived from dob at read time.

Framework changes (flag to OWC on re-vendor):
- crud._to_dict honors __audit_redact__, keeping ciphertext out of
  audit_log and version_history.
- crud._to_dict serializes date (not only datetime) for JSON columns.
- CRUDService.history annotation uses builtins.list (method shadowed it).

98 tests passing, 94.83% coverage; ruff + mypy clean.
Coverage trace at docs/coverage/registrant.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:25:50 -04:00

368 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, Iterator
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.auth.security import create_access_token, hash_password
from ypms.db import get_db
from ypms.framework.crud import REDACTED
from ypms.framework.pii_audit import PII_READ_ACTION
from ypms.framework.version_history import VersionHistory
from ypms.main import create_app
from ypms.seed.permissions import seed_permissions
from ypms.seed.run import seed_roles
from ypms.users.models import ASSURANCE_STANDARD, Role, User, UserRole
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 roles(db: Session) -> dict[str, Role]:
r = seed_roles(db)
seed_permissions(db, r)
db.flush()
return r
@pytest.fixture
def make_user(db: Session, roles: dict[str, Role]) -> Callable[..., tuple[User, Headers]]:
def _make(username: str, *role_codes: str) -> tuple[User, Headers]:
user = User(
username=username,
password_hash=hash_password("unused-in-these-tests"),
is_active=True,
assurance_level=ASSURANCE_STANDARD,
)
db.add(user)
db.flush()
for code in role_codes:
db.add(UserRole(user_id=user.id, role_id=roles[code].id))
db.flush()
db.refresh(user)
token = create_access_token(
sub=username, uid=user.id, roles=list(role_codes),
assurance_level=ASSURANCE_STANDARD, mfa_verified=False,
)
return user, {"Authorization": f"Bearer {token}"}
return _make
@pytest.fixture
def staff(make_user: Callable[..., tuple[User, Headers]]) -> tuple[User, Headers]:
return make_user("oyp.staff", "OYP_STAFF")
@pytest.fixture
def api(db: Session) -> Iterator[TestClient]:
app = create_app()
def _override() -> Iterator[Session]:
yield db
app.dependency_overrides[get_db] = _override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
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
r = api.post(f"/registrants/{rid}/restore", headers=staff[1])
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