598 lines
21 KiB
Python
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()
|