from __future__ import annotations import unittest from datetime import datetime, timezone from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from govoplan_access.backend.db.models import Account, Group, User from govoplan_calendar.backend.db.models import ( CalendarCollection, CalendarEvent, CalendarMigrationBatch, CalendarMigrationResource, CalendarOutboxOperation, CalendarSyncCredential, CalendarSyncSource, CalendarViewPreference, ) from govoplan_calendar.backend.dsar_provider import ( CALENDAR_DSAR_CAPABILITY, CalendarDsarProvider, ) from govoplan_calendar.backend.manifest import manifest 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, execute_data_subject_erasure, plan_data_subject_erasure, search_data_subject_request, ) class _Registry: def __init__( self, provider: CalendarDsarProvider, *, calendar_active: bool = True, ) -> None: self.provider = provider self.calendar_active = calendar_active def capability_names(self): return (CALENDAR_DSAR_CAPABILITY,) def capability_owner(self, name): self._assert_capability(name) return "calendar" def tenant_entitlement_resolver(self): calendar_active = self.calendar_active class _Resolver: @staticmethod def resolve(session, tenant_id): del session, tenant_id return type( "State", (), {"effective_modules": ("calendar",) if calendar_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": "calendar"})(),) @staticmethod def _assert_capability(name: str) -> None: if name != CALENDAR_DSAR_CAPABILITY: raise KeyError(name) class CalendarDsarProviderTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine("sqlite:///:memory:", future=True) Base.metadata.create_all( bind=self.engine, tables=[ Account.__table__, User.__table__, Group.__table__, ChangeSequenceEntry.__table__, DataSubjectRequest.__table__, CalendarCollection.__table__, CalendarEvent.__table__, CalendarViewPreference.__table__, CalendarSyncSource.__table__, CalendarSyncCredential.__table__, CalendarOutboxOperation.__table__, CalendarMigrationBatch.__table__, CalendarMigrationResource.__table__, ], ) self.session = sessionmaker(bind=self.engine, future=True)() now = datetime.now(timezone.utc) account = Account( id="account-1", email="subject@example.test", normalized_email="subject@example.test", display_name="Subject", ) other_account = Account( id="account-2", email="other@example.test", normalized_email="other@example.test", display_name="Other", ) self.user = User( id="membership-1", tenant_id="tenant-1", account_id=account.id, email="subject@example.test", display_name="Subject", ) other_user = User( id="membership-2", tenant_id="tenant-1", account_id=other_account.id, email="other@example.test", display_name="Other", ) self.collection = CalendarCollection( id="calendar-subject", tenant_id="tenant-1", slug="subject-calendar", name="Subject calendar", description="Subject-owned calendar", owner_type="user", owner_id=self.user.id, visibility="private", created_by_user_id=self.user.id, metadata_={"secret": "collection-secret-do-not-export"}, ) target_collection = CalendarCollection( id="calendar-target", tenant_id="tenant-1", slug="target-calendar", name="Target calendar", owner_type="user", owner_id=self.user.id, visibility="private", created_by_user_id=self.user.id, ) other_collection = CalendarCollection( id="calendar-other", tenant_id="tenant-1", slug="other-calendar", name="Other calendar", owner_type="user", owner_id=other_user.id, visibility="private", created_by_user_id=other_user.id, ) tenant_two_collection = CalendarCollection( id="calendar-tenant-2", tenant_id="tenant-2", slug="tenant-two", name="Tenant two secret calendar", owner_type="tenant", visibility="tenant", ) self.event = CalendarEvent( id="event-subject", tenant_id="tenant-1", calendar_id=self.collection.id, uid="subject-event@example.test", summary="Subject appointment", description="Information visible to the subject", location="Town hall", start_at=now, end_at=now, organizer={ "value": "mailto:organizer@example.test", "params": {"CN": ["Organizer"]}, }, attendees=[ { "value": "mailto:Subject@Example.Test", "params": {"CN": ["Subject"], "PARTSTAT": ["ACCEPTED"]}, }, { "value": "mailto:other@example.test", "params": {"CN": ["Unrelated Person"]}, }, ], categories=["citizen-service"], reminders=[{"minutes": 15, "token": "reminder-secret-do-not-export"}], attachments=[{"href": "/private/attachment-do-not-export"}], source_kind="caldav", source_href="/remote/event-do-not-export.ics", etag="event-etag-do-not-export", raw_ics="raw-ics-do-not-export", icalendar={"private": "icalendar-object-do-not-export"}, metadata_={"secret": "event-secret-do-not-export"}, ) unrelated_event = CalendarEvent( id="event-other", tenant_id="tenant-1", calendar_id=self.collection.id, uid="unrelated@example.test", summary="Unrelated event do not export", description="Unrelated event body do not export", start_at=now, end_at=now, organizer={"value": "mailto:other@example.test"}, attendees=[], ) tenant_two_event = CalendarEvent( id="event-tenant-2", tenant_id="tenant-2", calendar_id=tenant_two_collection.id, uid="tenant-two@example.test", summary="Tenant two event do not export", start_at=now, end_at=now, organizer={"value": "mailto:subject@example.test"}, attendees=[], ) self.source = CalendarSyncSource( id="source-subject", tenant_id="tenant-1", calendar_id=self.collection.id, source_kind="caldav", collection_url="https://private.example.test/calendar-do-not-export", display_name="Subject CalDAV", auth_type="basic", username="connector-user-do-not-export", credential_ref="credential-ref-do-not-export", sync_token="sync-token-do-not-export", ctag="ctag-do-not-export", last_status="failed", last_error="provider-error-do-not-export", metadata_={"secret": "source-secret-do-not-export"}, ) target_source = CalendarSyncSource( id="source-target", tenant_id="tenant-1", calendar_id=target_collection.id, source_kind="caldav", collection_url="https://private.example.test/target-do-not-export", display_name="Target CalDAV", auth_type="none", ) credential = CalendarSyncCredential( id="credential-subject", tenant_id="tenant-1", credential_kind="basic", label="Subject credential", secret_encrypted="ciphertext-do-not-export", created_by_user_id=self.user.id, metadata_={"password": "credential-password-do-not-export"}, ) self.preference = CalendarViewPreference( id="preference-subject", tenant_id="tenant-1", user_id=self.user.id, dim_weekends=True, workday_start_hour=8, workday_end_hour=17, ) operation = CalendarOutboxOperation( id="operation-subject", tenant_id="tenant-1", source_id=self.source.id, event_id=self.event.id, operation_kind="put", resource_href="/private/outbox-href-do-not-export", payload_ics="outbox-payload-do-not-export", payload_fingerprint="a" * 64, expected_etag="expected-etag-do-not-export", idempotency_key="idempotency-do-not-export", status="succeeded", available_at=now, lease_token="lease-do-not-export", remote_etag="remote-etag-do-not-export", last_error="outbox-error-do-not-export", metadata_={"secret": "outbox-secret-do-not-export"}, ) migration = CalendarMigrationBatch( id="migration-subject", tenant_id="tenant-1", status="active", phase="copying_destination", source_calendar_id=self.collection.id, target_calendar_id=target_collection.id, source_sync_source_id=self.source.id, target_sync_source_id=target_source.id, total_resources=1, total_events=1, created_by_user_id=self.user.id, authorization_evidence={"secret": "authorization-do-not-export"}, last_error="migration-error-do-not-export", ) migration_resource = CalendarMigrationResource( id="migration-resource-subject", tenant_id="tenant-1", batch_id=migration.id, source_href="/source/private-do-not-export", source_expected_etag="source-etag-do-not-export", destination_href="/destination/private-do-not-export", event_ids=[self.event.id], status="copy_pending", last_error="resource-error-do-not-export", ) self.session.add_all( [ account, other_account, self.user, other_user, self.collection, target_collection, other_collection, tenant_two_collection, self.event, unrelated_event, tenant_two_event, self.source, target_source, credential, self.preference, operation, migration, migration_resource, ] ) self.session.commit() self.provider = CalendarDsarProvider() self.subject = DsarSubjectRef( membership_id=self.user.id, email="subject@example.test", ) def tearDown(self) -> None: self.session.close() self.engine.dispose() def test_manifest_publishes_protocol_conforming_provider(self) -> None: provided_names = {item.name for item in manifest.provides_interfaces} self.assertIn(CALENDAR_DSAR_CAPABILITY, provided_names) provider = manifest.capability_factories[CALENDAR_DSAR_CAPABILITY](None) self.assertIsInstance(provider, DsarProvider) self.assertIn( "calendar.privacy.data-subject-requests", {topic.id for topic in manifest.documentation}, ) def test_search_is_tenant_scoped_minimized_and_party_specific(self) -> None: records = self._records() resource_types = {record.resource_type for record in records} self.assertTrue( { "calendar_collection", "calendar_event", "calendar_view_preference", "calendar_sync_credential", "calendar_sync_source", "calendar_outbox_operation", "calendar_migration_batch", "calendar_migration_resource", }.issubset(resource_types) ) event = next( record for record in records if record.resource_type == "calendar_event" ) self.assertEqual( "subject@example.test", event.data["matching_attendees"][0]["email"], ) self.assertEqual([], event.data["organizer"] or []) serialized = repr([record.to_dict() for record in records]) for hidden in ( "event-other", "Unrelated event do not export", "Unrelated event body do not export", "other@example.test", "Unrelated Person", "event-tenant-2", "Tenant two event do not export", "raw-ics-do-not-export", "icalendar-object-do-not-export", "/remote/event-do-not-export.ics", "event-etag-do-not-export", "/private/attachment-do-not-export", "collection-secret-do-not-export", "event-secret-do-not-export", "reminder-secret-do-not-export", "calendar-do-not-export", "connector-user-do-not-export", "credential-ref-do-not-export", "sync-token-do-not-export", "ctag-do-not-export", "provider-error-do-not-export", "source-secret-do-not-export", "ciphertext-do-not-export", "credential-password-do-not-export", "outbox-href-do-not-export", "outbox-payload-do-not-export", "expected-etag-do-not-export", "idempotency-do-not-export", "lease-do-not-export", "remote-etag-do-not-export", "outbox-error-do-not-export", "authorization-do-not-export", "migration-error-do-not-export", "/source/private-do-not-export", "source-etag-do-not-export", "/destination/private-do-not-export", "resource-error-do-not-export", ): self.assertNotIn(hidden, serialized) def test_conflicting_email_references_fail_closed_for_attendee_data(self) -> None: records = self.provider.search_subject( self.session, tenant_id="tenant-1", subject=DsarSubjectRef( email="subject@example.test", external_references={"calendar.email": "other@example.test"}, ), ) self.assertEqual((), records) def test_plan_retains_evidence_and_only_executes_preference_deletion(self) -> None: records = self._records() actions = self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=self.subject, records=records, ) self.assertTrue({"retain", "manual_review"}.issubset({a.kind for a in actions})) self.assertTrue( any( action.action_id == "calendar:retain:calendar_event:event-subject" for action in actions ) ) self.assertEqual( {"calendar:delete:calendar_view_preference:preference-subject"}, {action.action_id for action in actions if action.executable}, ) def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None: action = self._preference_delete_action() wrong_tenant = self.provider.execute_erasure( self.session, tenant_id="tenant-2", subject=self.subject, actions=(action,), request_id="dsar-wrong-tenant", ) self.assertEqual("blocked", wrong_tenant[0].status) first = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(action,), request_id="dsar-calendar-1", ) self.assertEqual("executed", first[0].status) self.assertIsNone(self.session.get(CalendarViewPreference, self.preference.id)) repeated = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(action,), request_id="dsar-calendar-1", ) self.assertEqual("unchanged", repeated[0].status) def test_execution_blocks_when_preference_owner_changed_after_planning( self, ) -> None: action = self._preference_delete_action() self.preference.user_id = "membership-2" self.session.flush() result = self.provider.execute_erasure( self.session, tenant_id="tenant-1", subject=self.subject, actions=(action,), request_id="dsar-stale", ) self.assertEqual("blocked", result[0].status) self.assertIsNotNone( self.session.get(CalendarViewPreference, self.preference.id) ) def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled( self, ) -> None: request = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-CALENDAR-1", request_kind="access_and_erasure", subject=self.subject, purpose="Respond to an authorized privacy request.", legal_basis="Article 15 and 17 GDPR", due_at=None, requested_by_account_id="privacy-officer", ) self.session.commit() registry = _Registry(self.provider) search_data_subject_request( self.session, registry=registry, row=request, expected_revision=1, ) self.assertEqual("searched", request.status) self.assertEqual(["calendar"], request.coverage["covered_modules"]) plan_data_subject_erasure( self.session, registry=registry, row=request, expected_revision=2, ) executable_ids = [ action["action_id"] for action in request.erasure_plan["actions"] if action["executable"] ] execute_data_subject_erasure( self.session, registry=registry, row=request, expected_revision=3, action_ids=executable_ids, ) self.assertEqual("completed", request.status) disabled = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-CALENDAR-DISABLED", request_kind="access", subject=self.subject, purpose="Verify disabled-module coverage.", legal_basis="Article 15 GDPR", due_at=None, requested_by_account_id="privacy-officer", ) search_data_subject_request( self.session, registry=_Registry(self.provider, calendar_active=False), row=disabled, expected_revision=1, ) self.assertEqual(0, disabled.search_result["record_count"]) self.assertEqual( [CALENDAR_DSAR_CAPABILITY], disabled.coverage["inactive_provider_capabilities"], ) def _records(self): return self.provider.search_subject( self.session, tenant_id="tenant-1", subject=self.subject, ) def _preference_delete_action(self): return next( action for action in self.provider.plan_erasure( self.session, tenant_id="tenant-1", subject=self.subject, records=self._records(), ) if action.resource_type == "calendar_view_preference" and action.executable ) if __name__ == "__main__": unittest.main()