feat(poll): add governed DSAR coverage

This commit is contained in:
2026-08-21 12:15:56 +02:00
parent 1891996f13
commit 02ec5423b9
4 changed files with 853 additions and 2 deletions
+506
View File
@@ -0,0 +1,506 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollParticipationSubmission,
PollResponse,
)
POLL_DSAR_CAPABILITY = dsar_capability_name("poll")
_MAX_RECORDS = 5_000
_MAX_ANSWERS = 1_000
_MAX_ANSWER_BYTES = 256 * 1024
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
respondent_ids: tuple[str, ...]
actor_ids: tuple[str, ...]
email: str | None
poll_id: str | None
invitation_id: str | None
response_id: str | None
class PollDsarProvider:
provider_id = "poll"
module_id = "poll"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
invitation_conditions = []
if selectors.respondent_ids:
invitation_conditions.append(
PollInvitation.respondent_id.in_(selectors.respondent_ids)
)
if selectors.email:
invitation_conditions.append(
func.lower(PollInvitation.email) == selectors.email
)
invitations = db.query(PollInvitation).filter(
PollInvitation.tenant_id == tenant_id,
or_(*invitation_conditions),
)
if selectors.poll_id:
invitations = invitations.filter(PollInvitation.poll_id == selectors.poll_id)
if selectors.invitation_id:
invitations = invitations.filter(PollInvitation.id == selectors.invitation_id)
invitation_rows = _limited(
invitations,
PollInvitation.created_at,
PollInvitation.id,
label="invitation",
)
records.extend(_invitation_record(row) for row in invitation_rows)
linked_response_ids: tuple[str, ...] = ()
if invitation_rows:
invitation_ids = [row.id for row in invitation_rows]
linked_rows = (
db.query(PollParticipationSubmission.response_id)
.filter(
PollParticipationSubmission.tenant_id == tenant_id,
PollParticipationSubmission.invitation_id.in_(invitation_ids),
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(linked_rows) > _MAX_RECORDS:
raise ValueError(
"Poll DSAR participation-link limit exceeded; narrow selectors."
)
linked_response_ids = tuple(
dict.fromkeys(str(response_id) for (response_id,) in linked_rows)
)
response_conditions = []
if selectors.invitation_id:
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
else:
if selectors.respondent_ids:
response_conditions.append(
PollResponse.respondent_id.in_(selectors.respondent_ids)
)
if linked_response_ids:
response_conditions.append(PollResponse.id.in_(linked_response_ids))
if response_conditions:
responses = db.query(PollResponse).filter(
PollResponse.tenant_id == tenant_id,
or_(*response_conditions),
)
if selectors.poll_id:
responses = responses.filter(PollResponse.poll_id == selectors.poll_id)
if selectors.response_id:
responses = responses.filter(PollResponse.id == selectors.response_id)
records.extend(
_response_record(row)
for row in _limited(
responses,
PollResponse.submitted_at,
PollResponse.id,
label="response",
)
)
if selectors.actor_ids:
polls = db.query(Poll).filter(
Poll.tenant_id == tenant_id,
Poll.created_by_user_id.in_(selectors.actor_ids),
)
transitions = db.query(PollLifecycleTransition).filter(
PollLifecycleTransition.tenant_id == tenant_id,
PollLifecycleTransition.actor_user_id.in_(selectors.actor_ids),
)
if selectors.poll_id:
polls = polls.filter(Poll.id == selectors.poll_id)
transitions = transitions.filter(
PollLifecycleTransition.poll_id == selectors.poll_id
)
records.extend(
_creator_attribution(row)
for row in _limited(
polls,
Poll.created_at,
Poll.id,
label="creator attribution",
)
)
records.extend(
_transition_attribution(row)
for row in _limited(
transitions,
PollLifecycleTransition.created_at,
PollLifecycleTransition.id,
label="lifecycle attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Poll DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Poll DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
participation = record.resource_type in {
"poll_response",
"poll_invitation",
}
actions.append(
DsarErasureActionRef(
action_id=(
f"poll:{'manual_review' if participation else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if participation else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if participation else "Retain ") + record.title,
rationale=(
"Removing or anonymizing participation may change published "
"results, response-update behavior, or retained invitation "
"evidence and therefore requires the Poll owner and retention "
"authority to review the effect."
if participation
else record.retention_reason
or "Poll actor attribution remains governance evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Poll DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Poll DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Poll participation remains unchanged pending result and "
"retention-impact review."
if action.kind == "manual_review"
else "Poll lifecycle attribution remains governance evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("poll.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("poll.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("poll.identity"),
references.get("identity.id"),
),
"respondent_id": _coalesce(
references.get("poll.respondent"),
references.get("poll.respondent_id"),
),
"email": _coalesce_email(subject.email, references.get("poll.email")),
"poll_id": _coalesce(
references.get("poll.poll"), references.get("poll.poll_id")
),
"invitation_id": _coalesce(
references.get("poll.invitation"),
references.get("poll.invitation_id"),
),
"response_id": _coalesce(
references.get("poll.response"),
references.get("poll.response_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
base_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_respondent = _optional_string(values["respondent_id"])
if direct_respondent and base_ids and direct_respondent not in base_ids:
return None
respondent_ids = base_ids or ((direct_respondent,) if direct_respondent else ())
email = _optional_string(values["email"])
if not respondent_ids and not email:
return None
return _SubjectSelectors(
respondent_ids=respondent_ids,
actor_ids=base_ids,
email=email,
poll_id=_optional_string(values["poll_id"]),
invitation_id=_optional_string(values["invitation_id"]),
response_id=_optional_string(values["response_id"]),
)
def _response_record(row: PollResponse) -> DsarRecordRef:
answers = _answers(row.answers)
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_response",
resource_id=row.id,
category="identified_poll_participation",
title=f"Poll response: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"poll_kind": row.poll.kind,
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"answers": answers,
"submitted_at": _iso(row.submitted_at),
"retired_at": _iso(row.deleted_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Response erasure or anonymization requires Poll result and retention review."
),
)
def _invitation_record(row: PollInvitation) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_invitation",
resource_id=row.id,
category="poll_invitation_and_contact",
title=f"Poll invitation: {row.poll.title[:500]}",
data={
"poll_id": row.poll_id,
"poll_title": row.poll.title[:500],
"respondent_id": row.respondent_id,
"respondent_label": (row.respondent_label or "")[:500] or None,
"email": row.email,
"expires_at": _iso(row.expires_at),
"revoked_at": _iso(row.revoked_at),
"last_used_at": _iso(row.last_used_at),
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.updated_at),
retention_reason=(
"Invitation erasure requires participation and response-link review."
),
)
def _creator_attribution(row: Poll) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_creator_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll creator attribution",
data={
"poll_id": row.id,
"kind": row.kind,
"status": row.status,
"visibility": row.visibility,
"opens_at": _iso(row.opens_at),
"closes_at": _iso(row.closes_at),
"created_at": _iso(row.created_at),
"activity": "created_poll",
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll creator attribution is governance evidence.",
)
def _transition_attribution(row: PollLifecycleTransition) -> DsarRecordRef:
return DsarRecordRef(
provider_id="poll",
module_id="poll",
resource_type="poll_lifecycle_actor_attribution",
resource_id=row.id,
category="poll_governance_attribution",
title="Poll lifecycle actor attribution",
data={
"poll_id": row.poll_id,
"action": row.action,
"from_status": row.from_status,
"to_status": row.to_status,
"created_at": _iso(row.created_at),
},
observed_at=_aware(row.created_at),
immutable_evidence=True,
retention_reason="Poll lifecycle attribution is governance evidence.",
)
def _answers(value: object) -> list[object]:
if not isinstance(value, list) or len(value) > _MAX_ANSWERS:
raise ValueError("Poll response answers exceed the DSAR bound.")
try:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Poll response answers are not JSON serializable.") from exc
if len(encoded) > _MAX_ANSWER_BYTES:
raise ValueError("Poll response answer payload exceeds the DSAR byte bound.")
return json.loads(encoded.decode("utf-8"))
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Poll DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _coalesce_email(*values: str | None) -> str | None | object:
normalized = {
str(value).strip().casefold()
for value in values
if str(value or "").strip()
}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Poll DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"poll_response",
"poll_invitation",
"poll_creator_attribution",
"poll_lifecycle_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "poll" or record.module_id != "poll":
raise ValueError("Poll DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Poll DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "poll" or action.module_id != "poll":
raise ValueError("Poll DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("poll:"):
raise ValueError("Poll DSAR action identity is invalid.")
__all__ = ["POLL_DSAR_CAPABILITY", "PollDsarProvider"]
+62 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationTopic,
MigrationSpec,
ModuleContext,
@@ -18,6 +19,7 @@ from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.db.base import Base
from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
from govoplan_poll.backend.dsar_provider import POLL_DSAR_CAPABILITY, PollDsarProvider
MODULE_ID = "poll"
MODULE_NAME = "Poll"
@@ -144,6 +146,11 @@ def _poll_participation_gateway_provider(context: ModuleContext) -> object:
return _poll_scheduling_provider(context)
def _dsar_provider(context: ModuleContext) -> PollDsarProvider:
del context
return PollDsarProvider()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -159,6 +166,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.signed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.governed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name=POLL_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -168,6 +176,17 @@ manifest = ModuleManifest(
capability_factories={
CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider,
CAPABILITY_POLL_PARTICIPATION_GATEWAY: _poll_participation_gateway_provider,
POLL_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
POLL_DSAR_CAPABILITY: CapabilityDocumentation(
label="Poll data-subject request provider",
summary=(
"Exports identified responses, invitation contact data, and minimized "
"operator attribution without token or gateway secrets."
),
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
@@ -196,7 +215,49 @@ manifest = ModuleManifest(
label="Poll",
),
),
documentation=DOCUMENTATION,
documentation=(
DocumentationTopic(
id="poll.data-subject-requests",
title="Poll data-subject requests",
summary=(
"Export identified responses and invitations while preserving result "
"integrity and the boundary around anonymous participation."
),
body=(
"Poll correlates exact respondent identifiers and normalized email "
"addresses inside the active tenant. A matching invitation can resolve "
"its explicitly linked participation submission and response without "
"exposing the signed token. Subject-owned responses include bounded "
"answers, respondent labels, Poll context, and retirement state. "
"Invitations include contact and lifecycle state but never token hashes, "
"gateway configuration, participation policy, metadata, fingerprints, "
"or idempotency values. Creator and lifecycle activity is exported only "
"as minimized attribution. Truly anonymous responses have no stable "
"subject selector and cannot be correlated. Participation erasure "
"requires manual result and retention review; no automatic action "
"silently changes a Poll outcome."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "participant", "organizer", "auditor"),
related_modules=("core", "scheduling", "notifications", "mail"),
metadata={
"help_contexts": ["privacy.data-subject-requests"],
"consequence_classes": {
"export_identified_response": (
"Returns bounded subject-owned answers and Poll context."
),
"anonymous_limitation": (
"Cannot correlate a response that deliberately has no subject identifier."
),
"review_participation_erasure": (
"Requires result-integrity and retention review."
),
},
},
),
*DOCUMENTATION,
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
+284
View File
@@ -0,0 +1,284 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollParticipationSubmission,
PollResponse,
)
from govoplan_poll.backend.dsar_provider import POLL_DSAR_CAPABILITY, PollDsarProvider
from govoplan_poll.backend.manifest import manifest
NOW = datetime(2026, 8, 21, 16, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: PollDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (POLL_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != POLL_DSAR_CAPABILITY:
raise KeyError(name)
return "poll"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("poll",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != POLL_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "poll"})(),)
class PollDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = PollDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
poll = Poll(
id="poll-1",
tenant_id="tenant-1",
slug="resident-availability",
title="Resident appointment availability",
description="Institutional description",
kind="availability",
status="open",
visibility="private",
result_visibility="after_close",
allow_anonymous=True,
allow_response_update=True,
min_choices=1,
created_by_user_id="account-1",
metadata_={"secret": "poll-metadata-do-not-export"},
)
self.session.add(poll)
self.session.flush()
invitation = PollInvitation(
id="invitation-1",
tenant_id="tenant-1",
poll_id="poll-1",
token_hash="token-hash-do-not-export",
respondent_id="account-1",
respondent_label="Ada Example",
email="Ada@Example.DE",
expires_at=NOW,
last_used_at=NOW,
response_gateway_={"secret": "gateway-do-not-export"},
participation_policy_={"secret": "policy-do-not-export"},
metadata_={"secret": "invitation-metadata-do-not-export"},
)
response = PollResponse(
id="response-1",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id="account-1",
respondent_label="Ada Example",
answers=[{"option_id": "option-a", "available": True}],
submitted_at=NOW,
metadata_={"secret": "response-metadata-do-not-export"},
)
anonymous = PollResponse(
id="response-anonymous",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id=None,
answers=[{"private": "anonymous-answer-do-not-correlate"}],
submitted_at=NOW,
)
other = PollResponse(
id="response-other",
tenant_id="tenant-1",
poll_id="poll-1",
respondent_id="account-other",
respondent_label="Other Person",
answers=[{"private": "other-answer-do-not-export"}],
submitted_at=NOW,
)
self.session.add_all((invitation, response, anonymous, other))
self.session.flush()
self.session.add(
PollParticipationSubmission(
id="submission-1",
tenant_id="tenant-1",
poll_id="poll-1",
invitation_id="invitation-1",
response_id="response-1",
idempotency_key="submission-idempotency-do-not-export",
request_fingerprint="submission-fingerprint-do-not-export",
)
)
self.session.add(
PollLifecycleTransition(
id="transition-1",
tenant_id="tenant-1",
poll_id="poll-1",
action="open",
from_status="draft",
to_status="open",
idempotency_key="transition-idempotency-do-not-export",
actor_user_id="account-1",
metadata_={"secret": "transition-metadata-do-not-export"},
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1", email="ada@example.de")
def test_search_exports_identified_participation_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"poll_response",
"poll_invitation",
"poll_creator_attribution",
"poll_lifecycle_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("option-a", exported)
self.assertIn("Ada@Example.DE", exported)
for excluded in (
"token-hash-do-not-export",
"gateway-do-not-export",
"policy-do-not-export",
"invitation-metadata-do-not-export",
"response-metadata-do-not-export",
"submission-idempotency-do-not-export",
"submission-fingerprint-do-not-export",
"transition-idempotency-do-not-export",
"transition-metadata-do-not-export",
"anonymous-answer-do-not-correlate",
"other-answer-do-not-export",
):
self.assertNotIn(excluded, exported)
def test_email_only_follows_explicit_invitation_response_link(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="ADA@EXAMPLE.DE"),
)
self.assertEqual(
{"poll_invitation", "poll_response"},
{record.resource_type for record in records},
)
def test_poll_narrowing_conflicts_and_anonymous_limit(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"poll.poll": "poll-1"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"poll.respondent": "account-other"},
),
)
poll_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(external_references={"poll.poll": "poll-1"}),
)
self.assertTrue(narrowed)
self.assertEqual((), conflict)
self.assertEqual((), poll_only)
self.assertNotIn(
"response-anonymous", {record.resource_id for record in narrowed}
)
def test_erasure_requires_review_and_preserves_results(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,
)
self.assertEqual(
{"manual_review", "retain"}, {action.kind for action in actions}
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-poll-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertIsNone(self.session.get(PollResponse, "response-1").deleted_at)
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(POLL_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POLL-1",
request_kind="access",
subject=self._subject(),
purpose="Poll participation access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(4, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -25,7 +25,7 @@ class PollManifestTests(unittest.TestCase):
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.provides_interfaces})
self.assertIn(CAPABILITY_POLL_PARTICIPATION_GATEWAY, manifest.capability_factories)
self.assertEqual(manifest.version, "0.1.11")
self.assertEqual(manifest.version, "0.1.18")
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})