from __future__ import annotations import json import unittest from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool from govoplan_core.core.concurrency import RevisionConflictError from govoplan_core.core.dsar import ( DsarErasureActionRef, DsarExecutionResultRef, DsarRecordRef, DsarSubjectRef, ) from govoplan_core.db.base import Base from govoplan_core.privacy.dsar_workflow import ( DataSubjectRequest, create_data_subject_request, data_subject_export, execute_data_subject_erasure, plan_data_subject_erasure, search_data_subject_request, ) class _Provider: provider_id = "example" module_id = "example" def __init__(self) -> None: self.executions = 0 def search_subject(self, session, *, tenant_id, subject): del session, tenant_id, subject return ( DsarRecordRef( provider_id=self.provider_id, module_id=self.module_id, resource_type="profile", resource_id="profile-1", category="personal", title="Example profile", data={"name": "Ada"}, ), DsarRecordRef( provider_id=self.provider_id, module_id=self.module_id, resource_type="audit_evidence", resource_id="event-1", category="evidence", title="Decision event", immutable_evidence=True, retention_reason="Required decision evidence.", ), ) def plan_erasure(self, session, *, tenant_id, subject, records): del session, tenant_id, subject, records return ( DsarErasureActionRef( action_id="example:anonymize:profile-1", provider_id=self.provider_id, module_id=self.module_id, kind="anonymize", resource_type="profile", resource_id="profile-1", title="Anonymize profile", rationale="Remove profile data.", executable=True, irreversible=True, ), ) def execute_erasure(self, session, *, tenant_id, subject, actions, request_id): del session, tenant_id, subject, request_id self.executions += 1 return tuple( DsarExecutionResultRef( action_id=action.action_id, status="executed", summary="Profile anonymized.", ) for action in actions ) class _Registry: def __init__(self, provider: _Provider) -> None: self.provider = provider def capability_names(self): return ("privacy.dsar.example", "privacy.dsar.inactive") def capability_owner(self, name): return { "privacy.dsar.example": "example", "privacy.dsar.inactive": "inactive", }[name] def tenant_entitlement_resolver(self): class _Resolver: @staticmethod def resolve(session, tenant_id): del session, tenant_id return type( "State", (), {"effective_modules": ("example", "without-provider")}, )() return _Resolver() def require_tenant_capability(self, name, session, **kwargs): del name, session, kwargs return self.provider def manifests(self): return ( type("Manifest", (), {"id": "example"})(), type("Manifest", (), {"id": "without-provider"})(), ) class DsarWorkflowTests(unittest.TestCase): def setUp(self) -> None: self.engine = create_engine( "sqlite+pysqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) DataSubjectRequest.__table__.create(self.engine) self.session: Session = sessionmaker( bind=self.engine, expire_on_commit=False, )() self.provider = _Provider() self.registry = _Registry(self.provider) def tearDown(self) -> None: self.session.close() Base.metadata.drop_all(self.engine, tables=[DataSubjectRequest.__table__]) self.engine.dispose() def test_search_plan_export_and_idempotent_execution(self) -> None: row = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-2026-001", request_kind="access_and_erasure", subject=DsarSubjectRef(email="ada@example.test"), purpose="Respond to a verified data-subject request.", legal_basis="Article 15 and 17 GDPR", due_at=None, requested_by_account_id="account-operator", ) self.session.commit() search_data_subject_request( self.session, registry=self.registry, row=row, expected_revision=1, ) self.assertEqual("searched", row.status) self.assertEqual(2, row.search_result["record_count"]) self.assertEqual(["without-provider"], row.coverage["modules_without_provider"]) self.assertEqual( ["privacy.dsar.inactive"], row.coverage["inactive_provider_capabilities"], ) plan_data_subject_erasure( self.session, registry=self.registry, row=row, expected_revision=2, ) actions = row.erasure_plan["actions"] self.assertEqual(2, len(actions)) self.assertEqual(1, row.erasure_plan["retained_count"]) executable_id = next(item["action_id"] for item in actions if item["executable"]) execute_data_subject_erasure( self.session, registry=self.registry, row=row, expected_revision=3, action_ids=[executable_id], ) self.assertEqual("completed", row.status) self.assertEqual(1, self.provider.executions) export = json.loads(data_subject_export(row)) self.assertEqual("govoplan.dsars.export.v1", export["schema"]) self.assertEqual("DSAR-2026-001", export["request"]["reference"]) self.assertEqual(64, len(export["manifest_sha256"])) with self.assertRaises(RevisionConflictError): search_data_subject_request( self.session, registry=self.registry, row=row, expected_revision=1, ) def test_access_only_request_cannot_create_erasure_plan(self) -> None: row = create_data_subject_request( self.session, tenant_id="tenant-1", reference="DSAR-2026-002", request_kind="access", subject=DsarSubjectRef(account_id="account-1"), purpose="Provide access information.", legal_basis=None, due_at=None, requested_by_account_id="account-operator", ) search_data_subject_request( self.session, registry=self.registry, row=row, expected_revision=1, ) with self.assertRaisesRegex(ValueError, "does not include erasure"): plan_data_subject_erasure( self.session, registry=self.registry, row=row, expected_revision=2, ) if __name__ == "__main__": unittest.main()