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>
This commit is contained in:
Chris TonjesandClaude Opus 5.5 committed 2026-09-30 19:25:50 -04:00
1 parent 185b5d2af3
commit 5dd828f0e5
15 files changed
+1098 -3

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ def db() -> Iterator[Session]:
import ypms.framework.version_history # noqa: F401
import ypms.security.models # noqa: F401
import ypms.users.models # noqa: F401
import ypms.youth.models # noqa: F401
from ypms.db import Base
# StaticPool + check_same_thread=False share the same in-memory DB across
View File
Whitespace-only changes.
+367
View File
@@ -0,0 +1,367 @@
"""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
+99
View File
@@ -0,0 +1,99 @@
"""Unit tests for column crypto, SSN normalization, and Registrant model rules."""
from __future__ import annotations
from collections.abc import Iterator
from datetime import date
import pytest
from ypms.security import column_crypto
from ypms.security.column_crypto import ColumnCryptoError, decrypt, encrypt
from ypms.settings import get_settings
from ypms.youth.models import Registrant, age_on
from ypms.youth.schemas import normalize_ssn
# ---------------- column crypto ----------------
def test_roundtrip_and_fresh_nonce() -> None:
a = encrypt("123456789", column="registrants.ssn")
b = encrypt("123456789", column="registrants.ssn")
assert a != b
assert a.startswith("v1.")
assert decrypt(a, column="registrants.ssn") == "123456789"
def test_ciphertext_is_bound_to_column() -> None:
token = encrypt("123456789", column="registrants.ssn")
with pytest.raises(ColumnCryptoError):
decrypt(token, column="registrants.bank_account")
@pytest.mark.parametrize("token", ["", "v1.", "v2.abcd", "v1.not-base64!!", "v1.AAAA"])
def test_malformed_ciphertext_rejected(token: str) -> None:
with pytest.raises(ColumnCryptoError):
decrypt(token, column="registrants.ssn")
@pytest.fixture
def short_key(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setenv("COLUMN_ENCRYPTION_KEY", "too-short")
get_settings.cache_clear()
column_crypto._aead.cache_clear()
yield
monkeypatch.undo()
get_settings.cache_clear()
column_crypto._aead.cache_clear()
@pytest.mark.usefixtures("short_key")
def test_short_key_material_refused() -> None:
with pytest.raises(ValueError, match="at least 32 bytes"):
encrypt("x", column="c")
# ---------------- SSN normalization ----------------
@pytest.mark.parametrize("raw", ["123-45-6789", "123456789", " 123 45 6789 "])
def test_normalize_ssn_accepts_common_forms(raw: str) -> None:
assert normalize_ssn(raw) == "123456789"
def test_normalize_ssn_error_does_not_echo_input() -> None:
with pytest.raises(ValueError) as e:
normalize_ssn("123-45-678")
assert "678" not in str(e.value)
# ---------------- model ----------------
def test_ssn_is_write_only() -> None:
r = Registrant(first_name="A", last_name="B", dob=date(2010, 1, 1), ssn="123456789")
with pytest.raises(AttributeError):
_ = r.ssn # type: ignore[misc]
assert r.ssn_last4 == "6789"
assert r.decrypt_ssn() == "123456789"
def test_clearing_ssn_clears_both_columns() -> None:
r = Registrant(first_name="A", last_name="B", dob=date(2010, 1, 1), ssn="123456789")
r.ssn = None
assert r.ssn_encrypted is None and r.ssn_last4 is None
assert r.ssn_masked is None
assert r.decrypt_ssn() is None
@pytest.mark.parametrize(
("dob", "today", "age"),
[
(date(2008, 10, 1), date(2026, 9, 30), 17), # day before 18th birthday
(date(2008, 9, 30), date(2026, 9, 30), 18), # on 18th birthday
(date(2008, 2, 29), date(2026, 2, 28), 17), # leap-day birthday
(date(2008, 2, 29), date(2026, 3, 1), 18),
],
)
def test_age_on_boundaries(dob: date, today: date, age: int) -> None:
assert age_on(dob, today) == age