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>
368 lines
12 KiB
Python
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
|