"""Unit tests for the enc:v1 PHI encryption layer and encrypted model fields.""" import base64 import hashlib import os from datetime import date, datetime, timezone from unittest import mock from django.db import connection from django.test import SimpleTestCase, TestCase from access_core.identity.models import Beneficiary from access_core.intake.models import IntakeRecord from access_core.services import phi_crypto from access_core.services.phi_crypto import ( PhiCryptoError, decrypt_value, encrypt_value, is_encrypted, ) KEY_A = base64.b64encode(hashlib.sha256(b"phi-test-key-a").digest()).decode() KEY_B = base64.b64encode(hashlib.sha256(b"phi-test-key-b").digest()).decode() class _EnvMixin: """Isolate PHI key env vars and the module-level backend cache per test.""" def setUp(self): super().setUp() phi_crypto._backends.clear() self._env = mock.patch.dict( os.environ, {"PHI_LOCAL_ENCRYPTION_KEY": KEY_A, "PHI_KMS_KEY_ID": ""}, ) self._env.start() def tearDown(self): self._env.stop() phi_crypto._backends.clear() super().tearDown() class EncryptDecryptTest(_EnvMixin, SimpleTestCase): def test_local_roundtrip(self): envelope = encrypt_value("1950-01-01") self.assertTrue(envelope.startswith("enc:v1:local:")) self.assertEqual(decrypt_value(envelope), "1950-01-01") def test_uniqueness_per_encryption(self): first = encrypt_value("secret-patient-dob") second = encrypt_value("secret-patient-dob") self.assertNotEqual(first, second) self.assertEqual(decrypt_value(second), "secret-patient-dob") def test_legacy_plaintext_passthrough_on_decrypt(self): self.assertEqual(decrypt_value("1950-01-01"), "1950-01-01") self.assertEqual(decrypt_value(""), "") def test_wrong_key_raises(self): envelope = encrypt_value("1950-01-01") phi_crypto._backends.clear() with ( mock.patch.dict(os.environ, {"PHI_LOCAL_ENCRYPTION_KEY": KEY_B}), self.assertRaises(PhiCryptoError), ): decrypt_value(envelope) def test_tampered_ciphertext_raises(self): envelope = encrypt_value("1950-01-01") parts = envelope.split(":", 3) tampered_char = "A" if parts[3][-1] != "A" else "B" tampered = ":".join(parts[:3] + [parts[3][:-1] + tampered_char]) with self.assertRaises(PhiCryptoError): decrypt_value(tampered) def test_malformed_envelope_raises(self): with self.assertRaises(PhiCryptoError): decrypt_value("enc:v1:local") with self.assertRaises(PhiCryptoError): decrypt_value(f"enc:v2:local:{encrypt_value('x').split(':', 3)[3]}") def test_unconfigured_raises(self): phi_crypto._backends.clear() with ( mock.patch.dict( os.environ, {"PHI_LOCAL_ENCRYPTION_KEY": "", "PHI_KMS_KEY_ID": ""} ), self.assertRaises(PhiCryptoError), ): encrypt_value("1950-01-01") def test_kms_mode_selected_when_key_id_set(self): phi_crypto._backends.clear() with mock.patch.dict(os.environ, {"PHI_KMS_KEY_ID": "alias/phi-test"}): self.assertEqual(phi_crypto._default_mode(), "kms") class IsEncryptedTest(SimpleTestCase): def test_envelope_detection(self): good = "enc:v1:local:dGVzdA==" self.assertTrue(is_encrypted(good)) self.assertFalse(is_encrypted("1950-01-01")) self.assertFalse(is_encrypted("enc:v2:local:dGVzdA==")) self.assertFalse(is_encrypted(None)) self.assertFalse(is_encrypted(date(1950, 1, 1))) class EncryptedFieldTest(_EnvMixin, TestCase): def test_intake_dob_roundtrip(self): record = IntakeRecord.objects.create( external_form_id="crypto-1", first_name="Jane", last_name="Doe", dob=date(1950, 1, 1), sex="female", ) with connection.cursor() as cursor: cursor.execute( "SELECT dob FROM intake_intakerecord WHERE external_form_id = %s", ["crypto-1"], ) raw = cursor.fetchone()[0] self.assertTrue(raw.startswith("enc:v1:local:")) self.assertEqual(decrypt_value(raw), "1950-01-01") self.assertEqual(IntakeRecord.objects.get(pk=record.pk).dob, date(1950, 1, 1)) def test_string_dob_input_coerced(self): record = IntakeRecord.objects.create( external_form_id="crypto-2", first_name="Sam", last_name="Lee", dob="1965-03-21", sex="male", ) self.assertEqual(IntakeRecord.objects.get(pk=record.pk).dob, date(1965, 3, 21)) def test_legacy_plaintext_row_readable(self): record = IntakeRecord.objects.create( external_form_id="crypto-3", first_name="Old", last_name="Row", dob=date(1948, 7, 4), sex="female", ) with connection.cursor() as cursor: # Simulate a pre-migration plaintext row. cursor.execute( "UPDATE intake_intakerecord SET dob = %s WHERE external_form_id = %s", ["1948-07-04", "crypto-3"], ) self.assertEqual(IntakeRecord.objects.get(pk=record.pk).dob, date(1948, 7, 4)) def test_no_double_encryption_on_resave(self): record = IntakeRecord.objects.create( external_form_id="crypto-4", first_name="Re", last_name="Save", dob=date(1970, 5, 5), sex="other", ) with connection.cursor() as cursor: cursor.execute( "SELECT dob FROM intake_intakerecord WHERE external_form_id = %s", ["crypto-4"], ) raw_before = cursor.fetchone()[0] record.save(update_fields=["dob"]) with connection.cursor() as cursor: cursor.execute( "SELECT dob FROM intake_intakerecord WHERE external_form_id = %s", ["crypto-4"], ) raw_after = cursor.fetchone()[0] self.assertEqual(decrypt_value(raw_after), "1970-05-05") field = IntakeRecord._meta.get_field("dob") self.assertEqual(field.get_prep_value(raw_before), raw_before) def test_beneficiary_dob_roundtrip(self): beneficiary = Beneficiary.objects.create( first_name="Kim", last_name="Nguyen", dob=date(1982, 11, 30), sex="female", ) with connection.cursor() as cursor: cursor.execute( "SELECT dob FROM identity_beneficiary WHERE first_name = %s AND last_name = %s", ["Kim", "Nguyen"], ) raw = cursor.fetchone()[0] self.assertTrue(raw.startswith("enc:v1:local:")) self.assertEqual( Beneficiary.objects.get(pk=beneficiary.pk).dob, date(1982, 11, 30) ) class TestEncryptedDateFieldToPython(TestCase): """Form/admin paths must yield real dates, not ISO strings. Regression: EncryptedDateField inherited TextField.to_python, so saving an admin form left intake.dob as a str in memory and the post_save alignment signal crashed in FHIRBuilder ('str' object has no attribute 'isoformat'). """ def _field(self): return IntakeRecord._meta.get_field("dob") def test_to_python_parses_iso_string(self): self.assertEqual(self._field().to_python("1970-05-05"), date(1970, 5, 5)) def test_to_python_none_and_datetime(self): field = self._field() self.assertIsNone(field.to_python(None)) self.assertEqual( field.to_python(datetime(1982, 11, 30, 12, tzinfo=timezone.utc)), date(1982, 11, 30), ) def test_parse_failure_message_never_contains_plaintext(self): """The decrypted value is PHI — a corrupted stored date must not leak it into the exception message (which propagates into logs).""" # Leading 10 chars must NOT parse as an ISO date, or no error is raised. secret = "NOT-A-DATE-CONFIDENTIAL-DOB-PAYLOAD" envelope = encrypt_value(secret) with self.assertRaises(PhiCryptoError) as ctx: self._field().from_db_value(envelope, None, None) self.assertNotIn("CONFIDENTIAL", str(ctx.exception)) self.assertNotIn(secret, str(ctx.exception)) def test_modelform_save_keeps_date_type_in_memory(self): from django.forms import modelform_factory record = IntakeRecord.objects.create( first_name="Form", last_name="Save", dob=date(1964, 2, 1), sex="female", external_form_id=f"form-{id(self)}-topython", ) form_class = modelform_factory(IntakeRecord, fields=["first_name", "dob"]) form = form_class({"first_name": "Form", "dob": "1964-02-01"}, instance=record) self.assertTrue(form.is_valid(), form.errors) saved = form.save() self.assertEqual(saved.dob, date(1964, 2, 1)) self.assertIsInstance(record.dob, date)