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