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>
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""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
|