Files
govoplan-calendar/tests/test_dsar_provider.py
T

598 lines
21 KiB
Python

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()