from __future__ import annotations from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import patch import pytest from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics from govoplan_core.core.modules import DocumentationContext @dataclass(frozen=True) class _Principal: scopes: frozenset[str] tenant_id: str = "tenant-1" def has(self, scope: str) -> bool: namespace, resource, _action = scope.split(":", 2) return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes class _Registry: def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None: interfaces = integrations | (interface_only or set()) self._capabilities = integrations | (capability_only or set()) self._manifests = ( SimpleNamespace( provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)), ), ) def manifests(self): return self._manifests def has_capability(self, name: str) -> bool: return name in self._capabilities def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"): return documentation_topics( DocumentationContext( registry=_Registry(integrations or set()), principal=_Principal(frozenset(scopes)), documentation_type=documentation_type, # type: ignore[arg-type] ) ) def test_campaign_runtime_documentation_provider_is_registered() -> None: from govoplan_campaign.backend.manifest import get_manifest assert documentation_topics in get_manifest().documentation_providers def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None: assert _topics({"docs:documentation:read"}) == () assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == () def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None: topic = _topics( { "campaigns:campaign:read", "campaigns:campaign:create", "campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:recipient:read", "campaigns:recipient:write", "campaigns:recipient:import", } )[0] configuration = topic.metadata["current_configuration"] assert "Role authorization: Create new campaigns." in configuration assert "Role authorization: Build exact recipient messages for review." in configuration assert not any("queue" in item.lower() for item in configuration) assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration) def test_runtime_documentation_requires_both_interface_and_capability() -> None: integration = "mail.campaign_delivery" scopes = {"campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"} for registry in ( _Registry(set(), interface_only={integration}), _Registry(set(), capability_only={integration}), ): topic = documentation_topics( DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user") )[0] assert not any("profile picker" in item for item in topic.metadata["current_configuration"]) topic = _topics(scopes, {integration})[0] assert any("Mail's actor-filtered profile picker" in item for item in topic.metadata["current_configuration"]) def test_mail_delivery_actions_are_not_presented_without_the_mail_contract() -> None: scopes = { "campaigns:campaign:read", "campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry", } without_mail = _topics(scopes)[0] with_mail = _topics(scopes, {"mail.campaign_delivery"})[0] assert not any("Queue an eligible" in item for item in without_mail.metadata["current_configuration"]) assert any("Queue an eligible" in item for item in with_mail.metadata["current_configuration"]) @pytest.mark.parametrize( ("integrations", "scopes", "expected"), [ ( {"files.campaign_attachments"}, {"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"}, "Managed campaign attachments can be previewed", ), ( {"addresses.lookup"}, {"campaigns:campaign:read", "campaigns:recipient:read"}, "Address records can be looked up", ), ( {"addresses.recipient_source"}, { "campaigns:campaign:read", "campaigns:campaign:update", "campaigns:recipient:read", "campaigns:recipient:write", "campaigns:recipient:import", }, "traceable recipient snapshot", ), ( {"notifications.dispatch"}, {"campaigns:campaign:read"}, "status changes", ), ], ) def test_runtime_documentation_reflects_optional_composition_permutations( integrations: set[str], scopes: set[str], expected: str, ) -> None: topic = _topics(scopes, integrations)[0] assert any(expected in item for item in topic.metadata["current_configuration"]) def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None: integrations = { "mail.campaign_delivery", "files.campaign_attachments", "addresses.lookup", "addresses.recipient_source", "notifications.dispatch", } topic = _topics({"*:*"}, integrations)[0] rendered = "\n".join( ( topic.title, topic.summary, topic.body, *topic.metadata["current_configuration"], *topic.metadata["limitations"], ) ) assert "Mail's actor-filtered profile picker" in rendered assert "Managed file versions" in rendered assert "Address records" in rendered assert "In-app notifications" in rendered assert topic.metadata["help_contexts"] == ["campaigns.list", "campaign.overview"] for technical_name in integrations: assert technical_name not in rendered assert "0.1." not in rendered assert "0.2." not in rendered assert "hostname" not in rendered.lower() assert "secret" not in rendered.lower() def test_runtime_documentation_states_the_effective_synchronous_limit() -> None: session = SimpleNamespace( get=lambda _model, _id: SimpleNamespace( settings={ "campaign_delivery_policy": { "synchronous_send_max_recipients": 12, } } ) ) with patch.dict("os.environ", {"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "25"}): topic = documentation_topics( DocumentationContext( registry=_Registry({"mail.campaign_delivery"}), principal=_Principal(frozenset({"campaigns:campaign:read", "campaigns:campaign:send"})), session=session, documentation_type="user", ) )[0] assert any( "Send now is limited to 12 eligible recipient job(s)" in item for item in topic.metadata["current_configuration"] ) def _visible_static_topics( scopes: set[str], *, modules: set[str] | None = None, capabilities: set[str] | None = None, ) -> set[str]: installed = modules or {"campaigns"} available_capabilities = capabilities or set() principal = _Principal(frozenset(scopes)) visible: set[str] = set() for topic in CAMPAIGN_USER_DOCUMENTATION: condition = topic.conditions[0] if not set(condition.required_modules).issubset(installed): continue if not set(condition.required_capabilities).issubset(available_capabilities): continue if not all(principal.has(scope) for scope in condition.required_scopes): continue visible.add(topic.id) return visible def test_campaign_manager_sees_only_authoring_tasks_from_the_static_handbook() -> None: visible = _visible_static_topics( { "campaigns:campaign:read", "campaigns:campaign:create", "campaigns:campaign:update", "campaigns:campaign:copy", "campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:recipient:read", "campaigns:recipient:write", "campaigns:recipient:import", "campaigns:report:read", } ) assert { "campaigns.workflow.create-campaign", "campaigns.workflow.create-editable-successor", "campaigns.workflow.import-recipients", "campaigns.workflow.view-delivery-report", }.issubset(visible) assert "campaigns.workflow.queue-delivery" not in visible assert "campaigns.workflow.send-small-controlled-run" not in visible assert "campaigns.workflow.export-delivery-report" not in visible assert "campaigns.workflow.share-campaign" not in visible assert "campaigns.workflow.archive-campaign" not in visible assert "campaigns.workflow.delete-untouched-draft" not in visible assert "campaigns.workflow.create-campaign" not in _visible_static_topics({"campaigns:campaign:create"}) def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_authority() -> None: scopes = { "campaigns:campaign:read", "campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:recipient:read", "campaigns:report:read", "mail:profile:use", } without_mail = _visible_static_topics(scopes) with_mail = _visible_static_topics( scopes, modules={"campaigns", "mail"}, capabilities={"mail.campaign_delivery"}, ) assert "campaigns.workflow.queue-delivery" not in without_mail assert "campaigns.workflow.send-small-controlled-run" not in without_mail assert "campaigns.workflow.queue-delivery" in with_mail assert "campaigns.workflow.send-small-controlled-run" in with_mail queue_topic = next( topic for topic in CAMPAIGN_USER_DOCUMENTATION if topic.id == "campaigns.workflow.queue-delivery" ) assert any(link.href == "/operator" for link in queue_topic.links) def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None: attachment_scopes = { "campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:validate", "campaigns:recipient:read", "files:file:read", "files:file:share", } source_scopes = { "campaigns:campaign:read", "campaigns:campaign:update", "campaigns:recipient:read", "campaigns:recipient:write", "campaigns:recipient:import", } files_visible = _visible_static_topics( attachment_scopes, modules={"campaigns", "files"}, capabilities={"files.campaign_attachments"}, ) addresses_visible = _visible_static_topics( source_scopes, modules={"campaigns", "addresses"}, capabilities={"addresses.recipient_source"}, ) assert "campaigns.workflow.use-managed-attachments" in files_visible assert "campaigns.workflow.import-address-source" in addresses_visible assert "campaigns.workflow.use-managed-attachments" not in _visible_static_topics(attachment_scopes) assert "campaigns.workflow.import-address-source" not in _visible_static_topics(source_scopes) def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() -> None: exporter = _visible_static_topics( { "campaigns:campaign:read", "campaigns:report:read", "campaigns:report:export", "campaigns:recipient:read", "campaigns:recipient:export", } ) custodian = _visible_static_topics( { "campaigns:campaign:read", "campaigns:campaign:share", "campaigns:campaign:archive", "campaigns:campaign:delete", } ) assert "campaigns.workflow.export-delivery-report" in exporter assert "campaigns.workflow.view-delivery-report" in exporter assert "campaigns.workflow.share-campaign" in custodian assert "campaigns.workflow.archive-campaign" in custodian assert "campaigns.workflow.delete-untouched-draft" in custodian assert "campaigns.workflow.export-delivery-report" not in custodian def test_aggregate_report_task_never_implies_recipient_detail_or_export_authority() -> None: visible = _visible_static_topics({"campaigns:report:read"}) assert visible == {"campaigns.workflow.view-aggregate-delivery-report"} topic = next( item for item in CAMPAIGN_USER_DOCUMENTATION if item.id == "campaigns.workflow.view-aggregate-delivery-report" ) assert topic.metadata["route"] == "/reports" assert "export" in topic.metadata["verification"].lower() def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None: from govoplan_campaign.backend.manifest import get_manifest topics = get_manifest().documentation ids = [topic.id for topic in topics] rendered_static = "\n".join( (topic.title + "\n" + topic.summary + "\n" + topic.body).lower() for topic in CAMPAIGN_USER_DOCUMENTATION ) known_help_contexts = { "campaigns.list", "campaign.overview", "campaign.settings", "campaign.fields", "campaign.template", "campaign.attachments", "campaign.recipients", "campaign.recipient-data", "campaign.server-settings", "campaign.global-settings", "campaign.review-send", "campaign.report", "campaign.audit", "campaign.json", } assert len(ids) == len(set(ids)) assert "single resend" not in rendered_static for topic in CAMPAIGN_USER_DOCUMENTATION: assert topic.metadata["kind"] == "workflow" assert topic.metadata["prerequisites"] assert topic.metadata["steps"] assert topic.metadata["outcome"] assert topic.metadata["verification"] assert set(topic.metadata["help_contexts"]).issubset(known_help_contexts) existing_user_workflows = { topic.id: topic for topic in topics if topic.id in { "campaigns.mail-profile-user-journey", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile", } } assert set(existing_user_workflows) == { "campaigns.mail-profile-user-journey", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile", } for topic in existing_user_workflows.values(): assert topic.metadata["help_contexts"]