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 ( DsarErasureActionRef, DsarProvider, DsarRecordRef, 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_records.backend.db.models import ( RecordAccessGrantRevision, RecordChronologyEntry, RecordClassRevision, RecordDispositionRevision, RecordFilePlanRevision, RecordHoldRevision, RecordIdentity, RecordItem, RecordRevision, RecordTransferPackageRevision, RecordVolumeRevision, ) from govoplan_records.backend.dsar_provider import ( RECORDS_DSAR_CAPABILITY, RecordsDsarProvider, ) from govoplan_records.backend.manifest import manifest NOW = datetime(2026, 8, 21, 15, 0, tzinfo=UTC) class _Registry: def __init__(self, provider: RecordsDsarProvider, *, active: bool = True) -> None: self.provider = provider self.active = active def capability_names(self): return (RECORDS_DSAR_CAPABILITY,) def capability_owner(self, name): self._assert_capability(name) return "records" def tenant_entitlement_resolver(self): active = self.active class _Resolver: @staticmethod def resolve(session, tenant_id): del session, tenant_id return type( "State", (), {"effective_modules": ("records",) if active else ()}, )() return _Resolver() def require_tenant_capability(self, name, session, **kwargs): del session, kwargs self._assert_capability(name) return self.provider def manifests(self): return (type("Manifest", (), {"id": "records"})(),) @staticmethod def _assert_capability(name: str) -> None: if name != RECORDS_DSAR_CAPABILITY: raise KeyError(name) class RecordsDsarProviderTests(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 = RecordsDsarProvider() self.assertIsInstance(self.provider, DsarProvider) self._seed_record_package() self._seed_unrelated_records() self._seed_operator_configuration() self.session.commit() def tearDown(self) -> None: self.session.close() self.engine.dispose() def _seed_record_package(self) -> None: identity = RecordIdentity( id="identity-row-1", tenant_id="tenant-1", record_id="record-1", record_number="2026/0001", created_by="clerk-1", ) self.session.add(identity) self.session.flush() self.session.add_all( ( RecordRevision( id="revision-row-1", tenant_id="tenant-1", record_id="record-1", identity_id=identity.id, revision=1, class_id="class-permit", file_plan_node_id="plan-permits", title="Resident parking permit record", description="Application by Ada Example", state="retention_running", source_authority_mode="native_authoritative", access_mode="tenant", purpose="decide resident parking permit", classification="personal", responsible_unit_id="unit-1", responsible_function_id="function-permits", external_reference={ "case_id": "case-1", "account_id": "applicant-1", "secret": "private-external-reference-do-not-export", }, institutional_context={ "membership_id": "membership-applicant-1", "secret": "private-context-do-not-export", }, search_text="private-search-text-do-not-export", valid_from=NOW, recorded_at=NOW, superseded_at=NOW + timedelta(minutes=1), changed_by="clerk-1", snapshot={"secret": "private-snapshot-do-not-export"}, ), RecordRevision( id="revision-row-2", tenant_id="tenant-1", record_id="record-1", identity_id=identity.id, revision=2, previous_revision_id="revision-row-1", class_id="class-permit", file_plan_node_id="plan-permits", title="Resident parking permit record", description="Application by Ada Example", state="retention_running", source_authority_mode="native_authoritative", access_mode="tenant", purpose="decide resident parking permit", classification="personal", responsible_unit_id="unit-1", responsible_function_id="function-permits", external_reference={ "case_id": "case-1", "account_id": "applicant-1", "secret": "private-external-reference-do-not-export", }, institutional_context={ "membership_id": "membership-applicant-1", "secret": "private-context-do-not-export", }, search_text="private-search-text-do-not-export", valid_from=NOW, recorded_at=NOW + timedelta(minutes=1), changed_by="clerk-1", closed_at=NOW + timedelta(minutes=1), retention_started_at=NOW + timedelta(minutes=1), retention_due_at=NOW + timedelta(days=3650), retention_rule={"secret": "private-rule-do-not-export"}, appraisal_state="appraised", appraisal={"secret": "private-appraisal-do-not-export"}, snapshot={"secret": "private-snapshot-do-not-export"}, ), ) ) self.session.add( RecordVolumeRevision( id="volume-row-1", tenant_id="tenant-1", volume_id="volume-1", record_id="record-1", revision=1, sequence=1, label="Application", state="closed", valid_from=NOW, recorded_at=NOW, changed_by="clerk-1", ) ) self.session.add( RecordItem( id="item-row-1", tenant_id="tenant-1", record_id="record-1", volume_id="volume-1", sequence=1, source_module="cases", resource_type="case_revision", resource_id="case-1", source_revision="revision-2", label="Permit case", relationship="decision_basis", filing_reason="The application belongs to this record.", purpose="document the permit decision", authority_mode="native_authoritative", content_sha256="a" * 64, content_type="application/json", size_bytes=2048, source_valid_from=NOW, source_recorded_at=NOW, launch_url="https://secret.example/do-not-export", filed_at=NOW + timedelta(minutes=1), filed_by="clerk-1", actor_assignment_id="private-assignment-do-not-export", actor_delegation_id="private-delegation-do-not-export", institutional_context={"secret": "private-item-context-do-not-export"}, source_metadata={"secret": "private-source-data-do-not-export"}, filing_metadata={"secret": "private-filing-data-do-not-export"}, idempotency_key="private-item-key-do-not-export", request_sha256="b" * 64, ) ) self.session.add( RecordChronologyEntry( id="chronology-row-1", tenant_id="tenant-1", record_id="record-1", event_id="event-1", event_type="record.item_filed", record_revision=2, summary="private-event-summary-do-not-export", occurred_at=NOW + timedelta(minutes=1), actor_id="clerk-1", actor_assignment_id="private-assignment-do-not-export", actor_delegation_id="private-delegation-do-not-export", purpose="document the permit decision", idempotency_key="private-event-key-do-not-export", request_sha256="c" * 64, institutional_context={"secret": "private-event-context-do-not-export"}, payload={"secret": "private-event-payload-do-not-export"}, ) ) self.session.add( RecordHoldRevision( id="hold-row-1", tenant_id="tenant-1", hold_id="hold-1", record_id="record-1", revision=1, status="released", reason="private-hold-reason-do-not-export", authority="private-hold-authority-do-not-export", scope={"secret": "private-hold-scope-do-not-export"}, effective_from=NOW + timedelta(minutes=2), released_at=NOW + timedelta(minutes=3), policy_refs=["private-policy-do-not-export"], institutional_context={"secret": "private-hold-context-do-not-export"}, recorded_at=NOW + timedelta(minutes=2), changed_by="clerk-1", idempotency_key="private-hold-key-do-not-export", request_sha256="d" * 64, ) ) self.session.add( RecordDispositionRevision( id="disposition-row-1", tenant_id="tenant-1", disposition_id="disposition-1", record_id="record-1", revision=1, action="transfer", status="approved", reason="private-disposition-reason-do-not-export", subject_revision=2, subject_sha256="e" * 64, consequence_preview={"secret": "private-preview-do-not-export"}, policy_refs=["private-policy-do-not-export"], approval_request_id="private-approval-do-not-export", proposed_by="clerk-1", reviewed_by="reviewer-1", reviewed_at=NOW + timedelta(minutes=4), institutional_context={ "secret": "private-disposition-context-do-not-export" }, recorded_at=NOW + timedelta(minutes=3), idempotency_key="private-disposition-key-do-not-export", request_sha256="f" * 64, ) ) self.session.add( RecordTransferPackageRevision( id="transfer-row-1", tenant_id="tenant-1", package_id="package-1", record_id="record-1", disposition_id="disposition-1", revision=1, record_revision=2, provider_id="archive-1", profile="xarchive", status="accepted", authority_mode="external_authoritative", manifest={"secret": "private-manifest-do-not-export"}, manifest_sha256="1" * 64, receipt={"secret": "private-receipt-do-not-export"}, receipt_sha256="2" * 64, external_reference="private-archive-reference-do-not-export", recovery_operation_id="private-recovery-id-do-not-export", simulated=False, institutional_context={ "secret": "private-transfer-context-do-not-export" }, recorded_at=NOW + timedelta(minutes=5), changed_by="clerk-1", idempotency_key="private-transfer-key-do-not-export", request_sha256="3" * 64, ) ) def _seed_unrelated_records(self) -> None: for suffix, tenant_id in (("2", "tenant-1"), ("3", "tenant-2")): identity = RecordIdentity( id=f"identity-row-{suffix}", tenant_id=tenant_id, record_id=f"record-{suffix}", record_number=f"2026/000{suffix}", created_by="other-clerk", ) self.session.add(identity) self.session.flush() self.session.add( RecordRevision( id=f"unrelated-revision-row-{suffix}", tenant_id=tenant_id, record_id=f"record-{suffix}", identity_id=identity.id, revision=1, class_id="class-permit", file_plan_node_id="plan-permits", title=f"Unrelated record {suffix}", description="unrelated-private-description", state="open", source_authority_mode="native_authoritative", access_mode="tenant", purpose="unrelated purpose", external_reference={}, institutional_context={}, search_text="unrelated-private-search", recorded_at=NOW, changed_by="other-clerk", snapshot={"secret": "unrelated-private-snapshot"}, ) ) def _seed_operator_configuration(self) -> None: self.session.add( RecordFilePlanRevision( id="plan-row-1", tenant_id="tenant-1", node_id="plan-permits", revision=1, code="10.20", label="Permits", active=True, recorded_at=NOW, institutional_context={"secret": "private-plan-context-do-not-export"}, changed_by="clerk-1", idempotency_key="private-plan-key-do-not-export", request_sha256="4" * 64, ) ) self.session.add( RecordClassRevision( id="class-row-1", tenant_id="tenant-1", class_id="class-permit", revision=1, file_plan_node_id="plan-permits", key="permit.application", label="Permit application", metadata_requirements=["private-metadata-rule-do-not-export"], allowed_source_types=["cases:case_revision"], retention_period_days=3650, access_mode="tenant", active=True, recorded_at=NOW, institutional_context={"secret": "private-class-context-do-not-export"}, changed_by="clerk-1", idempotency_key="private-class-key-do-not-export", request_sha256="5" * 64, ) ) def test_direct_record_exports_minimized_complete_lifecycle(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="applicant-1", external_references={"records.record": "record-1"}, ), ) resource_types = {record.resource_type for record in records} self.assertTrue( { "record_identity", "record_revision", "record_current_fact", "record_volume_revision", "record_item", "record_chronology_entry", "record_hold_revision", "record_disposition_revision", "record_transfer_package_revision", }.issubset(resource_types) ) self.assertNotIn( "record-2", json.dumps([record.to_dict() for record in records]) ) exported = json.dumps([record.to_dict() for record in records]) for secret in ( "private-snapshot-do-not-export", "private-search-text-do-not-export", "private-context-do-not-export", "private-event-summary-do-not-export", "private-event-payload-do-not-export", "private-hold-reason-do-not-export", "private-preview-do-not-export", "private-manifest-do-not-export", "private-receipt-do-not-export", "private-archive-reference-do-not-export", "private-item-key-do-not-export", ): self.assertNotIn(secret, exported) def test_authoritative_source_reference_correlates_the_record(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="applicant-1", external_references={"cases.case": "case-1"}, ), ) item = next( record for record in records if record.resource_type == "record_item" ) self.assertTrue(item.data["source_reference_matches"]) self.assertEqual("record-1", item.data["record_id"]) def test_operator_search_returns_attribution_without_record_contents(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef(account_id="clerk-1"), ) self.assertTrue(records) self.assertEqual( {"records_operator_attribution"}, {record.resource_type for record in records}, ) exported = json.dumps([record.to_dict() for record in records]) self.assertNotIn("Application by Ada Example", exported) self.assertNotIn("private-", exported) self.assertIn("changed_file_plan", exported) def test_account_search_exports_only_the_subjects_restricted_grant(self) -> None: self.session.add( RecordAccessGrantRevision( id="grant-row-subject-1", tenant_id="tenant-1", grant_id="grant-subject-1", record_id="record-1", revision=1, status="active", subject_type="account", subject_id="account-grantee-1", actions=["read"], allowed_purposes=["case-work"], reason="Sensitive internal assignment reason", valid_from=NOW, recorded_at=NOW, changed_by="records-admin-1", institutional_context={"case_id": "case-1"}, idempotency_key="grant-subject-create-1", request_sha256="a" * 64, ) ) self.session.commit() records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef(account_id="account-grantee-1"), ) grants = [ record for record in records if record.resource_type == "record_access_grant_revision" ] self.assertEqual(1, len(grants)) self.assertEqual(["case-work"], grants[0].data["allowed_purposes"]) self.assertNotIn("reason", grants[0].data) self.assertNotIn("account-grantee-1", json.dumps(grants[0].data)) def test_direct_selectors_fail_closed_on_conflict_or_wrong_tenant(self) -> None: conflict = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( external_references={ "records.record": "record-2", "records.item": "item-row-1", } ), ) alias_conflict = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( external_references={ "records.record": "record-1", "records.record_id": "record-2", } ), ) wrong_tenant = self.provider.search_subject( self.session, tenant_id="tenant-2", subject=DsarSubjectRef(external_references={"records.record": "record-1"}), ) mismatched_actor = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="someone-else", external_references={"records.record": "record-1"}, ), ) self.assertEqual((), conflict) self.assertEqual((), alias_conflict) self.assertEqual((), wrong_tenant) self.assertEqual((), mismatched_actor) def test_plan_retains_evidence_and_routes_current_fact_to_review(self) -> None: subject = DsarSubjectRef(external_references={"records.record": "record-1"}) records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=subject, ) actions = self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=subject, records=records, ) self.assertEqual(1, sum(action.kind == "manual_review" for action in actions)) self.assertGreater(sum(action.kind == "retain" for action in actions), 7) self.assertTrue(all(not action.executable for action in actions)) results = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=subject, actions=actions, request_id="dsar-1", ) self.assertTrue(all(result.status == "blocked" for result in results)) def test_foreign_records_and_actions_are_rejected(self) -> None: subject = DsarSubjectRef(external_references={"records.record": "record-1"}) with self.assertRaisesRegex(ValueError, "foreign provider record"): self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=subject, records=( DsarRecordRef( provider_id="cases", module_id="cases", resource_type="case", resource_id="case-1", category="case", title="Case", ), ), ) with self.assertRaisesRegex(ValueError, "foreign provider action"): self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=subject, actions=( DsarErasureActionRef( action_id="cases:retain:case:case-1", provider_id="cases", module_id="cases", kind="retain", resource_type="case", resource_id="case-1", title="Retain case", rationale="Evidence", executable=False, ), ), request_id="dsar-1", ) def test_core_workflow_reports_active_and_inactive_provider(self) -> None: row = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-RECORDS-1", request_kind="access", subject=DsarSubjectRef(external_references={"records.record": "record-1"}), purpose="Respond to a verified request.", legal_basis="Article 15 GDPR", due_at=None, requested_by_account_id="privacy-officer", ) self.session.commit() search_data_subject_request( self.session, registry=_Registry(self.provider), row=row, expected_revision=1, ) self.assertEqual( [RECORDS_DSAR_CAPABILITY], row.coverage["provider_capabilities"] ) self.assertGreater(row.search_result["record_count"], 8) inactive = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-RECORDS-2", request_kind="access", subject=DsarSubjectRef(external_references={"records.record": "record-1"}), purpose="Respond to a verified request.", legal_basis="Article 15 GDPR", due_at=None, requested_by_account_id="privacy-officer", ) self.session.commit() search_data_subject_request( self.session, registry=_Registry(self.provider, active=False), row=inactive, expected_revision=1, ) self.assertEqual([], inactive.coverage["provider_capabilities"]) self.assertEqual( [RECORDS_DSAR_CAPABILITY], inactive.coverage["inactive_provider_capabilities"], ) self.assertEqual(0, inactive.search_result["record_count"]) def test_manifest_registers_and_documents_the_capability(self) -> None: self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_factories) self.assertIn(RECORDS_DSAR_CAPABILITY, manifest.capability_documentation) self.assertIn( RECORDS_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}, ) self.assertTrue( any( topic.id == "records.data-subject-requests" and {"admin", "user"}.issubset(topic.documentation_types) for topic in manifest.documentation ) ) if __name__ == "__main__": unittest.main()