Files
govoplan-campaign/tests/test_dsar_provider.py
T

706 lines
24 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_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignSchedule,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
RecipientImportMappingProfile,
SendAttempt,
)
from govoplan_campaign.backend.dsar_provider import (
CAMPAIGN_DSAR_CAPABILITY,
CampaignDsarProvider,
)
from govoplan_campaign.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: CampaignDsarProvider,
*,
campaign_active: bool = True,
) -> None:
self.provider = provider
self.campaign_active = campaign_active
def capability_names(self):
return (CAMPAIGN_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "campaigns"
def tenant_entitlement_resolver(self):
campaign_active = self.campaign_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("campaigns",) if campaign_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": "campaigns"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != CAMPAIGN_DSAR_CAPABILITY:
raise KeyError(name)
class CampaignDsarProviderTests(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__,
Campaign.__table__,
CampaignShare.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
AttachmentBlob.__table__,
AttachmentInstance.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
ImapAppendAttempt.__table__,
PostboxDeliveryAttempt.__table__,
PrintOutputAttempt.__table__,
RecipientImportMappingProfile.__table__,
CampaignSchedule.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.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=self.account.id,
email="subject@example.test",
display_name="Subject",
)
self.other_user = User(
id="membership-2",
tenant_id="tenant-1",
account_id=other_account.id,
email="other@example.test",
display_name="Other",
)
self.campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id=self.other_user.id,
owner_user_id=self.other_user.id,
external_id="privacy-notice",
name="Privacy notice",
status="active",
)
self.version = CampaignVersion(
id="version-1",
campaign_id=self.campaign.id,
version_number=1,
workflow_state="built",
execution_snapshot_hash="a" * 64,
raw_json={
"entries": {
"inline": [
{
"id": "entry-subject",
"to": [
{
"email": "subject@example.test",
"name": "Subject Person",
}
],
"cc": [
{
"email": "other@example.test",
"name": "Unrelated person",
}
],
"body": "private-rendered-body-do-not-export",
"password": "inline-secret-do-not-export",
"case_reference": "CASE-SUBJECT-1",
},
{
"id": "entry-other",
"to": [{"email": "other@example.test"}],
"private_value": "other-recipient-data-do-not-export",
},
]
}
},
)
self.draft_version = CampaignVersion(
id="version-draft",
campaign_id=self.campaign.id,
version_number=2,
workflow_state="editing",
raw_json={
"entries": {
"inline": [
{
"id": "entry-draft-subject",
"to": [{"email": "subject@example.test"}],
"case_reference": "CASE-DRAFT-1",
}
]
}
},
)
self.job = CampaignJob(
id="job-subject",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
entry_index=0,
entry_id="entry-subject",
recipient_email="Subject@Example.Test",
subject="Your governed notice",
eml_storage_key="private/eml/key-do-not-export",
eml_local_path="/private/message-do-not-export.eml",
eml_size_bytes=512,
eml_sha256="b" * 64,
build_status="built",
validation_status="ready",
queue_status="completed",
send_status="smtp_accepted",
postbox_status="accepted",
print_status="accepted",
imap_status="appended",
attempt_count=1,
queued_at=now,
sent_at=now,
claim_token="job-claim-do-not-export",
resolved_recipients={
"from": {"email": "sender@example.test"},
"to": [{"email": "subject@example.test", "name": "Subject"}],
"cc": [{"email": "other@example.test", "name": "Other"}],
"legacy": ["other@example.test", "subject@example.test"],
},
resolved_attachments=[
{"storage_key": "resolved-attachment-key-do-not-export"}
],
)
other_job = CampaignJob(
id="job-other",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
entry_index=1,
entry_id="entry-other",
recipient_email="other@example.test",
subject="Other person's message",
build_status="built",
validation_status="ready",
)
tenant_two_campaign = Campaign(
id="campaign-tenant-2",
tenant_id="tenant-2",
external_id="other-tenant",
name="Other tenant data do not export",
)
tenant_two_version = CampaignVersion(
id="version-tenant-2",
campaign_id=tenant_two_campaign.id,
version_number=1,
raw_json={
"entries": {"inline": [{"to": [{"email": "subject@example.test"}]}]}
},
)
tenant_two_job = CampaignJob(
id="job-tenant-2",
tenant_id="tenant-2",
campaign_id=tenant_two_campaign.id,
campaign_version_id=tenant_two_version.id,
entry_index=0,
recipient_email="subject@example.test",
)
self.issue = CampaignIssue(
id="issue-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
job_id=self.job.id,
severity="warning",
code="delivery_warning",
message="issue-detail-do-not-export",
source="private-source-do-not-export",
behavior="review",
)
send_attempt = SendAttempt(
id="send-attempt-1",
job_id=self.job.id,
attempt_number=1,
status="accepted",
claim_token="attempt-claim-do-not-export",
smtp_status_code=250,
smtp_response="smtp-response-do-not-export",
error_message="transport-detail-do-not-export",
started_at=now,
finished_at=now,
)
postbox_attempt = PostboxDeliveryAttempt(
id="postbox-attempt-1",
tenant_id="tenant-1",
job_id=self.job.id,
target_key="target-key-do-not-export",
target_index=0,
attempt_number=1,
idempotency_key="postbox-idempotency-do-not-export",
status="accepted",
target_snapshot={"private": "snapshot-do-not-export"},
provider_delivery_id="delivery-1",
provider_message_id="message-1",
postbox_id="postbox-1",
address="subject@example.test",
evidence={"private": "postbox-evidence-do-not-export"},
started_at=now,
finished_at=now,
)
print_attempt = PrintOutputAttempt(
id="print-attempt-1",
tenant_id="tenant-1",
job_id=self.job.id,
attempt_number=1,
idempotency_key="print-idempotency-do-not-export",
status="accepted",
render_id="render-1",
artifact_sha256="c" * 64,
evidence={"private": "print-evidence-do-not-export"},
started_at=now,
finished_at=now,
)
self.share = CampaignShare(
id="share-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
target_type="user",
target_id=self.user.id,
permission="read",
created_by_user_id=self.other_user.id,
)
self.profile = RecipientImportMappingProfile(
id="mapping-1",
tenant_id="tenant-1",
owner_user_id=self.user.id,
name="Subject mapping",
column_count=2,
headers=["email", "case_reference"],
normalized_headers=["email", "case_reference"],
ordered_header_fingerprint="d" * 64,
unordered_header_fingerprint="e" * 64,
delimiter=";",
header_rows=1,
quoted=True,
value_separators=",;|",
mappings=[
{"header": "email", "field": "to.0.email"},
{"secret": "profile-secret-do-not-export"},
],
)
blob = AttachmentBlob(
id="blob-1",
tenant_id="tenant-1",
sha256="f" * 64,
size_bytes=42,
mime_type="application/pdf",
storage_bucket="private-bucket-do-not-export",
storage_key="private-attachment-key-do-not-export",
)
attachment = AttachmentInstance(
id="attachment-1",
tenant_id="tenant-1",
owner_user_id=self.other_user.id,
campaign_id=self.campaign.id,
blob_id=blob.id,
logical_name="notice",
filename="notice.pdf",
tags=["notice"],
metadata_={"secret": "attachment-secret-do-not-export"},
)
schedule = CampaignSchedule(
id="schedule-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
source_version_id=self.version.id,
created_by_user_id=self.other_user.id,
name="Recurring privacy notice",
delivery_mode="manual",
recurrence_kind="monthly",
starts_at=now,
next_fire_at=now,
max_occurrences=12,
source_snapshot={"private": "schedule-snapshot-do-not-export"},
source_snapshot_hash="1" * 64,
)
self.session.add_all(
[
self.account,
other_account,
self.user,
self.other_user,
self.campaign,
self.version,
self.draft_version,
self.job,
other_job,
tenant_two_campaign,
tenant_two_version,
tenant_two_job,
self.issue,
send_attempt,
postbox_attempt,
print_attempt,
self.share,
self.profile,
blob,
attachment,
schedule,
]
)
self.session.commit()
self.provider = CampaignDsarProvider()
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(CAMPAIGN_DSAR_CAPABILITY, provided_names)
provider = manifest.capability_factories[CAMPAIGN_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
self.assertIn(
"campaigns.privacy.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"campaign",
"campaign_version",
"campaign_recipient_job",
"campaign_message_artifact",
"campaign_delivery_issue",
"campaign_send_attempt",
"campaign_postbox_attempt",
"campaign_print_attempt",
"campaign_report_projection",
"campaign_share",
"recipient_import_mapping_profile",
"campaign_schedule",
"campaign_attachment",
}.issubset(resource_types)
)
report = next(
record
for record in records
if record.resource_type == "campaign_report_projection"
)
self.assertEqual(1, report.data["matched_job_count"])
artifact = next(
record
for record in records
if record.resource_type == "campaign_message_artifact"
)
self.assertEqual("b" * 64, artifact.data["sha256"])
self.assertEqual(512, artifact.data["size_bytes"])
serialized = repr([record.to_dict() for record in records])
for hidden in (
"job-tenant-2",
"Other tenant data do not export",
"job-other",
"other@example.test",
"Unrelated person",
"other-recipient-data-do-not-export",
"private-rendered-body-do-not-export",
"inline-secret-do-not-export",
"private/eml/key-do-not-export",
"/private/message-do-not-export.eml",
"resolved-attachment-key-do-not-export",
"job-claim-do-not-export",
"issue-detail-do-not-export",
"private-source-do-not-export",
"attempt-claim-do-not-export",
"smtp-response-do-not-export",
"transport-detail-do-not-export",
"target-key-do-not-export",
"postbox-idempotency-do-not-export",
"snapshot-do-not-export",
"postbox-evidence-do-not-export",
"print-idempotency-do-not-export",
"print-evidence-do-not-export",
"profile-secret-do-not-export",
"private-bucket-do-not-export",
"private-attachment-key-do-not-export",
"attachment-secret-do-not-export",
"schedule-snapshot-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_email_references_fail_closed_for_recipient_data(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"campaign.email": "other@example.test"},
),
)
self.assertEqual((), records)
def test_plan_retains_evidence_and_limits_execution_to_reversible_data(
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,
)
kinds = {action.kind for action in actions}
self.assertTrue({"retain", "manual_review", "revoke", "delete"}.issubset(kinds))
self.assertTrue(
any(
action.action_id
== "campaigns:retain:campaign_recipient_job:job-subject"
for action in actions
)
)
self.assertTrue(
any(
action.action_id == "campaigns:review:campaign_version:version-draft"
for action in actions
)
)
executable_ids = {action.action_id for action in actions if action.executable}
self.assertEqual(
{
"campaigns:revoke:campaign_share:share-1",
"campaigns:delete:recipient_import_mapping_profile:mapping-1",
},
executable_ids,
)
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
actions = self._executable_actions()
wrong_tenant = self.provider.execute_erasure(
self.session,
tenant_id="tenant-2",
subject=self.subject,
actions=actions,
request_id="dsar-wrong-tenant",
)
self.assertEqual({"blocked"}, {result.status for result in wrong_tenant})
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual({"executed"}, {result.status for result in first})
self.session.flush()
self.assertIsNotNone(self.share.revoked_at)
self.assertIsNone(
self.session.get(RecipientImportMappingProfile, self.profile.id)
)
repeated = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual({"unchanged"}, {result.status for result in repeated})
def test_execution_blocks_when_mapping_owner_changed_after_planning(self) -> None:
delete_action = next(
action
for action in self._executable_actions()
if action.resource_type == "recipient_import_mapping_profile"
)
self.profile.owner_user_id = self.other_user.id
self.session.flush()
result = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(delete_action,),
request_id="dsar-stale",
)
self.assertEqual("blocked", result[0].status)
self.assertIsNotNone(
self.session.get(RecipientImportMappingProfile, self.profile.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-CAMPAIGN-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(["campaigns"], request.coverage["covered_modules"])
self.assertEqual([], request.coverage["modules_without_provider"])
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-CAMPAIGN-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, campaign_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[CAMPAIGN_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
def _executable_actions(self):
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,
)
return tuple(action for action in actions if action.executable)
if __name__ == "__main__":
unittest.main()