from __future__ import annotations import json import unittest from datetime import UTC, datetime, timedelta from sqlalchemy import create_engine from sqlalchemy.orm import Session from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef from govoplan_core.db.base import Base from govoplan_core.privacy.dsar_workflow import ( create_data_subject_request, search_data_subject_request, ) from govoplan_encryption.backend.db.models import ( ContentProtectionRecord, EncryptionKeyOperation, EncryptionVault, ProtectionMigration, RecoveryApproval, RecoveryCeremony, ) from govoplan_encryption.backend.dsar_provider import ( ENCRYPTION_DSAR_CAPABILITY, EncryptionDsarProvider, ) from govoplan_encryption.backend.manifest import manifest NOW = datetime(2026, 8, 22, 11, 0, tzinfo=UTC) class _Registry: def __init__(self, provider: EncryptionDsarProvider) -> None: self.provider = provider def capability_names(self): return (ENCRYPTION_DSAR_CAPABILITY,) def capability_owner(self, name): if name != ENCRYPTION_DSAR_CAPABILITY: raise KeyError(name) return "encryption" def tenant_entitlement_resolver(self): class _Resolver: @staticmethod def resolve(session, tenant_id): del session, tenant_id return type("State", (), {"effective_modules": ("encryption",)})() return _Resolver() def require_tenant_capability(self, name, session, **kwargs): del session, kwargs if name != ENCRYPTION_DSAR_CAPABILITY: raise KeyError(name) return self.provider def manifests(self): return (type("Manifest", (), {"id": "encryption"})(),) class EncryptionDsarProviderTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all(self.engine) self.session = Session(self.engine) self.provider = EncryptionDsarProvider() self.assertIsInstance(self.provider, DsarProvider) self._seed() self.session.commit() def tearDown(self) -> None: self.session.close() self.engine.dispose() def _seed(self) -> None: self.session.add_all( ( EncryptionVault( id="vault-row-1", tenant_id="tenant-1", vault_id="vault-1", name="Resident postbox vault", provider_id="provider-secret-do-not-export", purpose="resident correspondence", profile_kind="institutional", scope_type="tenant", scope_id="scope-secret-do-not-export", policy_ref="policy-ref-do-not-export", recovery_quorum=2, state="active", revision=2, current_key_version=3, create_idempotency_key="vault-idempotency-do-not-export", create_request_digest="vault-digest-do-not-export", provenance={"secret": "vault-provenance-do-not-export"}, created_by="account-1", updated_by="account-1", created_at=NOW, updated_at=NOW, ), EncryptionKeyOperation( id="key-operation-1", tenant_id="tenant-1", vault_id="vault-1", key_version=3, operation="rotate", provider_id="provider-secret-do-not-export", state="complete", idempotency_key="key-idempotency-do-not-export", request_digest="key-request-digest-do-not-export", request_payload={"secret": "request-payload-do-not-export"}, error_code="provider-error-do-not-export", policy_decision_ref="policy-decision-do-not-export", assurance_evidence_ref="assurance-evidence-do-not-export", requested_by="account-1", completed_at=NOW, created_at=NOW, updated_at=NOW, ), ContentProtectionRecord( id="protection-row-1", envelope_id="envelope-1", tenant_id="tenant-1", owner_module="postbox", resource_type="message", resource_id="message-resource-do-not-export", profile_kind="institutional", profile_id="profile-secret-do-not-export", provider_id="provider-secret-do-not-export", vault_id="vault-1", key_version=3, algorithm_suite="AES-256-GCM", ciphertext_ref="ciphertext-ref-do-not-export", ciphertext_digest="ciphertext-digest-do-not-export", authenticated_context_digest="context-digest-do-not-export", wrapped_key_refs=["wrapped-key-ref-do-not-export"], state="active", source_envelope_id="source-envelope-do-not-export", migration_id="migration-secret-do-not-export", envelope_metadata={"secret": "envelope-metadata-do-not-export"}, idempotency_key="envelope-idempotency-do-not-export", request_digest="envelope-request-digest-do-not-export", policy_decision_ref="envelope-policy-do-not-export", registered_by="account-1", created_at=NOW, updated_at=NOW, ), ProtectionMigration( id="migration-1", tenant_id="tenant-1", source_envelope_id="source-envelope-ref-do-not-export", target_envelope_id="target-envelope-ref-do-not-export", target_provider_id="target-provider-do-not-export", target_vault_id="vault-1", target_key_version=4, target_algorithm_suite="AES-256-GCM", mode="rewrap", state="complete", policy_decision_ref="migration-policy-do-not-export", assurance_evidence_ref="migration-assurance-do-not-export", idempotency_key="migration-idempotency-do-not-export", request_digest="migration-request-digest-do-not-export", evidence_refs=["migration-evidence-do-not-export"], error_code="migration-error-do-not-export", provenance={"secret": "migration-provenance-do-not-export"}, requested_by="account-1", completed_at=NOW, created_at=NOW, updated_at=NOW, ), RecoveryCeremony( id="recovery-1", tenant_id="tenant-1", vault_id="vault-1", state="approved", requested_scope="scope-secret-do-not-export", reason="recovery-reason-do-not-export", quorum=2, revision=2, policy_decision_ref="recovery-policy-do-not-export", requester_assurance_ref="requester-assurance-do-not-export", requester_account_id="account-1", idempotency_key="recovery-idempotency-do-not-export", request_digest="recovery-request-digest-do-not-export", expires_at=NOW + timedelta(hours=1), execution_ref="execution-ref-do-not-export", provenance={"secret": "recovery-provenance-do-not-export"}, created_at=NOW, updated_at=NOW, ), RecoveryApproval( id="recovery-approval-1", tenant_id="tenant-1", recovery_id="recovery-1", approver_account_id="account-1", decision="approve", reason="approval-reason-do-not-export", assurance_evidence_ref="approval-assurance-do-not-export", idempotency_key="approval-idempotency-do-not-export", request_digest="approval-request-digest-do-not-export", created_at=NOW, updated_at=NOW, ), EncryptionVault( id="vault-row-other", tenant_id="tenant-2", vault_id="vault-other", name="Other tenant vault", provider_id="local", purpose="other", profile_kind="institutional", scope_type="tenant", policy_ref="policy", recovery_quorum=2, state="active", revision=1, create_idempotency_key="other-key", create_request_digest="other-digest", provenance={}, created_by="account-1", updated_by="account-1", ), ) ) @staticmethod def _subject() -> DsarSubjectRef: return DsarSubjectRef(account_id="account-1") def test_search_exports_minimized_custody_attribution(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self._subject() ) self.assertEqual( { "vault_actor_attribution", "key_operation_actor_attribution", "content_protection_actor_attribution", "protection_migration_actor_attribution", "recovery_request_actor_attribution", "recovery_approval_actor_attribution", }, {record.resource_type for record in records}, ) exported = json.dumps([record.to_dict() for record in records]) for excluded in ( "provider-secret-do-not-export", "scope-secret-do-not-export", "policy-ref-do-not-export", "vault-idempotency-do-not-export", "vault-digest-do-not-export", "vault-provenance-do-not-export", "key-idempotency-do-not-export", "key-request-digest-do-not-export", "request-payload-do-not-export", "provider-error-do-not-export", "policy-decision-do-not-export", "assurance-evidence-do-not-export", "message-resource-do-not-export", "profile-secret-do-not-export", "ciphertext-ref-do-not-export", "ciphertext-digest-do-not-export", "context-digest-do-not-export", "wrapped-key-ref-do-not-export", "source-envelope-do-not-export", "envelope-metadata-do-not-export", "target-provider-do-not-export", "migration-policy-do-not-export", "migration-assurance-do-not-export", "migration-evidence-do-not-export", "recovery-reason-do-not-export", "requester-assurance-do-not-export", "execution-ref-do-not-export", "approval-reason-do-not-export", "approval-assurance-do-not-export", ): self.assertNotIn(excluded, exported) def test_requires_exact_account_and_enforces_tenant(self) -> None: email_only = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef(email="custodian@example.test"), ) conflict = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="account-1", external_references={"encryption.account": "account-other"}, ), ) records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self._subject() ) self.assertEqual((), email_only) self.assertEqual((), conflict) self.assertNotIn("vault-row-other", {record.resource_id for record in records}) def test_recovery_narrowing_includes_request_and_approval_only(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="account-1", external_references={"encryption.recovery": "recovery-1"}, ), ) self.assertEqual( { "recovery_request_actor_attribution", "recovery_approval_actor_attribution", }, {record.resource_type for record in records}, ) def test_erasure_retains_cryptographic_custody_evidence(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self._subject() ) actions = self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=self._subject(), records=records, ) self.assertTrue(actions) self.assertTrue( all(action.kind == "retain" and not action.executable for action in actions) ) results = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self._subject(), actions=actions, request_id="dsar-encryption-1", ) self.assertTrue(all(result.status == "blocked" for result in results)) def test_manifest_and_core_workflow_discover_provider(self) -> None: self.assertIn(ENCRYPTION_DSAR_CAPABILITY, manifest.capability_factories) self.assertIn( "encryption.data-subject-requests", {topic.id for topic in manifest.documentation}, ) row = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-ENCRYPTION-1", request_kind="access", subject=self._subject(), purpose="Encryption custody access request", legal_basis=None, due_at=None, requested_by_account_id="operator-1", ) search_data_subject_request( self.session, registry=_Registry(self.provider), row=row, expected_revision=row.resource_revision, ) self.assertEqual("searched", row.status) self.assertEqual(6, row.search_result["record_count"]) if __name__ == "__main__": unittest.main()