"""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