from __future__ import annotations import unittest from datetime import datetime, timedelta, timezone from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db.models import Account, Group, User from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef from govoplan_core.db.base import Base from govoplan_core.privacy.dsar_workflow import ( DataSubjectRequest, create_data_subject_request, plan_data_subject_erasure, search_data_subject_request, ) from govoplan_postbox.backend.db.models import ( Postbox, PostboxAccessEvent, PostboxAddress, PostboxAttachmentReference, PostboxDelivery, PostboxGrouping, PostboxGroupingSource, PostboxMessage, PostboxMessageReceipt, PostboxParticipant, PostboxProtectionTransition, PostboxProtectionTransitionItem, PostboxRoute, PostboxTemplate, PostboxTemplateRevision, ) from govoplan_postbox.backend.dsar_provider import ( POSTBOX_DSAR_CAPABILITY, PostboxDsarProvider, ) from govoplan_postbox.backend.manifest import manifest class _Registry: def __init__(self, provider: object, *, active: bool = True): self.provider = provider self.active = active def capability_names(self): return (POSTBOX_DSAR_CAPABILITY,) def capability_owner(self, name): assert name == POSTBOX_DSAR_CAPABILITY return "postbox" 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": ("postbox",) if active else ()}, )() return Resolver() def require_tenant_capability(self, name, session, **kwargs): del session, kwargs assert name == POSTBOX_DSAR_CAPABILITY return self.provider class PostboxDsarProviderTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:", future=True) Base.metadata.create_all( self.engine, tables=[ Account.__table__, User.__table__, Group.__table__, ChangeSequenceEntry.__table__, DataSubjectRequest.__table__, PostboxTemplate.__table__, PostboxTemplateRevision.__table__, PostboxAddress.__table__, Postbox.__table__, PostboxMessage.__table__, PostboxParticipant.__table__, PostboxAttachmentReference.__table__, PostboxDelivery.__table__, PostboxRoute.__table__, PostboxMessageReceipt.__table__, PostboxGrouping.__table__, PostboxGroupingSource.__table__, PostboxAccessEvent.__table__, PostboxProtectionTransition.__table__, PostboxProtectionTransitionItem.__table__, ], ) self.session = sessionmaker(bind=self.engine, future=True)() now = datetime.now(timezone.utc) account = Account( id="account-subject", email="subject@example.test", normalized_email="subject@example.test", display_name="Subject", ) user = User( id="membership-subject", tenant_id="tenant-1", account_id=account.id, email="subject@example.test", display_name="Subject", ) template = PostboxTemplate( id="template-subject", tenant_id="tenant-1", slug="subject-template", name="Subject configured template", status="published", created_by=account.id, updated_by=account.id, ) revision = PostboxTemplateRevision( id="template-revision-subject", tenant_id="tenant-1", template_id=template.id, revision=1, created_by=account.id, published_at=now, ) address = PostboxAddress( id="address-1", tenant_id="tenant-1", address_key="office", address="office.postbox", status="active", ) postbox = Postbox( id="postbox-1", tenant_id="tenant-1", address_id=address.id, name="Office Postbox", classification="confidential", ) message = PostboxMessage( id="message-subject", tenant_id="tenant-1", postbox_id=postbox.id, subject="Subject request context", body_text="Bounded plaintext concerning the subject", status="delivered", classification="personal", producer_module="postbox", producer_resource_type="account_authored_message", producer_resource_id=account.id, authoring_key="authoring-secret-do-not-export", delivered_at=now, metadata_={"secret": "message-metadata-do-not-export"}, ) matching_participant = PostboxParticipant( id="participant-subject", tenant_id="tenant-1", message_id=message.id, kind="recipient", reference_type="account", reference_id=account.id, label="Subject Person", address="Subject@Example.Test", position=1, metadata_={"secret": "participant-metadata-do-not-export"}, ) unrelated_participant = PostboxParticipant( id="participant-other", tenant_id="tenant-1", message_id=message.id, kind="recipient", reference_type="account", reference_id="account-other", label="Unrelated Person Do Not Export", address="other@example.test", position=2, ) attachment = PostboxAttachmentReference( id="attachment-subject", tenant_id="tenant-1", message_id=message.id, reference_type="file_version", reference_id="file-version-1", name="subject-evidence.pdf", digest="digest-do-not-export", metadata_={"secret": "attachment-metadata-do-not-export"}, ) encrypted_message = PostboxMessage( id="message-encrypted", tenant_id="tenant-1", postbox_id=postbox.id, subject="Encrypted subject context", body_ciphertext=b"ciphertext-do-not-export", status="delivered", classification="personal", encryption_profile="server_envelope_v1", encryption_envelope_id="envelope-do-not-export", encryption_resource_id="resource-do-not-export", wrapped_keys=[{"wrapped_key_ref": "wrapped-key-do-not-export"}], external_recipient_tokens=[{"token_id": "external-token-do-not-export"}], delivered_at=now, ) encrypted_participant = PostboxParticipant( id="participant-encrypted-subject", tenant_id="tenant-1", message_id=encrypted_message.id, kind="recipient", reference_type="identity", reference_id="identity-subject", position=1, ) unrelated_message = PostboxMessage( id="message-other", tenant_id="tenant-1", postbox_id=postbox.id, subject="Unrelated message do not export", body_text="Unrelated body do not export", status="delivered", delivered_at=now, ) tenant_two_address = PostboxAddress( id="address-tenant-2", tenant_id="tenant-2", address_key="other", address="other.postbox", ) tenant_two_postbox = Postbox( id="postbox-tenant-2", tenant_id="tenant-2", address_id=tenant_two_address.id, name="Tenant two Postbox", ) tenant_two_message = PostboxMessage( id="message-tenant-2", tenant_id="tenant-2", postbox_id=tenant_two_postbox.id, subject="Tenant two message do not export", body_text="Tenant two body do not export", delivered_at=now, ) tenant_two_participant = PostboxParticipant( id="participant-tenant-2", tenant_id="tenant-2", message_id=tenant_two_message.id, kind="recipient", reference_type="account", reference_id=account.id, ) delivery = PostboxDelivery( id="delivery-subject", tenant_id="tenant-1", postbox_id=postbox.id, message_id=message.id, producer_module="postbox", producer_resource_type="account_authored_message", producer_resource_id=account.id, idempotency_key="delivery-idempotency-do-not-export", status="accepted", holder_count=1, target_snapshot={"secret": "target-snapshot-do-not-export"}, accepted_at=now, metadata_={"secret": "delivery-metadata-do-not-export"}, ) route = PostboxRoute( id="route-subject", tenant_id="tenant-1", delivery_id=delivery.id, source_postbox_id=postbox.id, source_message_id=message.id, target_postbox_id=postbox.id, target_message_id=message.id, route_kind="linked_copy", status="completed", depth=1, processed_at=now, policy_snapshot={"secret": "route-policy-do-not-export"}, ) receipt = PostboxMessageReceipt( id="receipt-subject", tenant_id="tenant-1", message_id=message.id, account_id=account.id, identity_id="identity-subject", assignment_id="assignment-subject", read_at=now, acknowledged_at=now, metadata_={"secret": "receipt-metadata-do-not-export"}, ) grouping = PostboxGrouping( id="grouping-subject", tenant_id="tenant-1", account_id=account.id, name="My work", is_default=True, settings={"secret": "grouping-settings-do-not-export"}, ) grouping.sources.append( PostboxGroupingSource( id="grouping-source-subject", tenant_id="tenant-1", postbox_id=postbox.id, position=0, ) ) access_event = PostboxAccessEvent( id="access-event-subject", tenant_id="tenant-1", postbox_id=postbox.id, message_id=message.id, account_id=account.id, identity_id="identity-subject", assignment_id="assignment-subject", action="read_message", outcome="allowed", reason_code="assigned", occurred_at=now, details={"secret": "access-details-do-not-export"}, ) transition = PostboxProtectionTransition( id="transition-subject", tenant_id="tenant-1", postbox_id=postbox.id, idempotency_key="transition-idempotency-do-not-export", source_profile="plaintext_v1", target_profile="server_envelope_v1", history_mode="migrate", authority_mode="institutional", required_quorum=1, evidence_refs=["evidence-ref-do-not-export"], reason="private transition reason do not export", state="completed", message_count=1, completed_count=1, requested_by=account.id, activated_at=now, completed_at=now, configuration_snapshot={"secret": "transition-config-do-not-export"}, ) transition_item = PostboxProtectionTransitionItem( id="transition-item-subject", tenant_id="tenant-1", transition_id=transition.id, message_id=message.id, source_profile="plaintext_v1", target_profile="server_envelope_v1", state="completed", source_digest="source-digest-do-not-export", target_digest="target-digest-do-not-export", completed_by=account.id, completed_at=now, evidence={"secret": "transition-item-evidence-do-not-export"}, ) self.session.add_all( [ account, user, template, revision, address, postbox, message, matching_participant, unrelated_participant, attachment, encrypted_message, encrypted_participant, unrelated_message, tenant_two_address, tenant_two_postbox, tenant_two_message, tenant_two_participant, delivery, route, receipt, grouping, access_event, transition, transition_item, ] ) self.session.commit() self.provider = PostboxDsarProvider() self.subject = DsarSubjectRef( account_id=account.id, identity_id="identity-subject", membership_id=user.id, email="subject@example.test", external_references={"postbox.assignment": "assignment-subject"}, ) def tearDown(self) -> None: self.session.close() self.engine.dispose() def test_manifest_and_minimized_tenant_scoped_search(self) -> None: self.assertIn( POSTBOX_DSAR_CAPABILITY, {item.name for item in manifest.provides_interfaces}, ) self.assertIsInstance( manifest.capability_factories[POSTBOX_DSAR_CAPABILITY](None), DsarProvider, ) records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self.subject, ) self.assertTrue( { "postbox_message", "postbox_participant", "postbox_attachment_reference", "postbox_delivery", "postbox_route", "postbox_message_receipt", "postbox_grouping", "postbox_access_event", "postbox_template", "postbox_template_revision", "postbox_protection_transition", "postbox_protection_transition_item", }.issubset({record.resource_type for record in records}) ) encrypted = next( record for record in records if record.resource_type == "postbox_message" and record.resource_id == "message-encrypted" ) self.assertEqual( "institution_managed_envelope", encrypted.data["content_state"] ) serialized = repr([record.to_dict() for record in records]) for hidden in ( "Unrelated Person Do Not Export", "other@example.test", "participant-other", "Unrelated message do not export", "Unrelated body do not export", "message-tenant-2", "Tenant two message do not export", "ciphertext-do-not-export", "envelope-do-not-export", "resource-do-not-export", "wrapped-key-do-not-export", "external-token-do-not-export", "authoring-secret-do-not-export", "message-metadata-do-not-export", "participant-metadata-do-not-export", "digest-do-not-export", "attachment-metadata-do-not-export", "delivery-idempotency-do-not-export", "target-snapshot-do-not-export", "delivery-metadata-do-not-export", "route-policy-do-not-export", "receipt-metadata-do-not-export", "grouping-settings-do-not-export", "access-details-do-not-export", "transition-idempotency-do-not-export", "evidence-ref-do-not-export", "private transition reason do not export", "transition-config-do-not-export", "source-digest-do-not-export", "target-digest-do-not-export", "transition-item-evidence-do-not-export", ): self.assertNotIn(hidden, serialized) def test_conflicting_selectors_fail_closed(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( account_id="account-subject", external_references={"postbox.account": "account-other"}, ), ) self.assertEqual((), records) def test_grouping_erasure_is_revalidated_and_idempotent(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( {"retain", "manual_review", "delete"}.issubset( {action.kind for action in actions} ) ) delete = next(action for action in actions if action.kind == "delete") grouping = self.session.get(PostboxGrouping, "grouping-subject") assert grouping is not None grouping.resource_revision += 1 self.session.commit() stale = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(delete,), request_id="request-1", ) self.assertEqual("blocked", stale[0].status) refreshed_records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self.subject ) refreshed_actions = self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=self.subject, records=refreshed_records, ) refreshed_delete = next( action for action in refreshed_actions if action.kind == "delete" ) executed = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(refreshed_delete,), request_id="request-1", ) self.assertEqual("executed", executed[0].status) self.assertIsNone(self.session.get(PostboxGrouping, "grouping-subject")) self.assertIsNotNone(self.session.get(PostboxMessage, "message-subject")) replay = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(refreshed_delete,), request_id="request-1", ) self.assertEqual("unchanged", replay[0].status) def test_core_workflow_discovers_active_and_skips_disabled_provider(self) -> None: request = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-POSTBOX-1", request_kind="access_and_erasure", subject=self.subject, purpose="Authorized request", legal_basis="GDPR", due_at=datetime.now(timezone.utc) + timedelta(days=30), requested_by_account_id="privacy-officer", ) self.session.commit() search_data_subject_request( self.session, registry=_Registry(self.provider), row=request, expected_revision=1, ) self.assertEqual(["postbox"], request.coverage["covered_modules"]) plan_data_subject_erasure( self.session, registry=_Registry(self.provider), row=request, expected_revision=2, ) self.assertTrue( any(action["executable"] for action in request.erasure_plan["actions"]) ) disabled = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-POSTBOX-OFF", request_kind="access", subject=self.subject, purpose="Coverage", legal_basis=None, due_at=None, requested_by_account_id="privacy-officer", ) search_data_subject_request( self.session, registry=_Registry(self.provider, active=False), row=disabled, expected_revision=1, ) self.assertEqual( [POSTBOX_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"], ) if __name__ == "__main__": unittest.main()