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

+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