feat(campaign): govern attachment reuse

This commit is contained in:
2026-08-20 05:20:07 +02:00
parent f4fe534ee1
commit 112ef9dc31
16 changed files with 705 additions and 2 deletions
+160
View File
@@ -7,6 +7,7 @@ import zipfile
from pathlib import Path
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.loader import validate_against_schema
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"delivery": {"imap_append_sent": {"enabled": False}},
})
def _attachment_reuse_config(
self,
*,
action: str,
allow_within: str = "none",
recipient_emails: tuple[str, ...] = (
"first@example.org",
"second@example.org",
),
duplicate_rules: bool = False,
) -> CampaignConfig:
rules = [{
"id": "shared-file",
"base_dir": "documents",
"file_filter": "shared.pdf",
"required": True,
}]
if duplicate_rules:
rules.append({**rules[0], "id": "shared-file-again"})
return CampaignConfig.model_validate({
"version": "1.0",
"campaign": {
"id": f"reuse-{action}-{allow_within}",
"name": "Attachment reuse",
"mode": "test",
},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {
"from": {"email": "sender@example.org", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Subject", "text": "Body"},
"attachments": {
"global": rules,
"reuse_policy": {
"action": action,
"allow_within": allow_within,
},
},
"entries": {
"inline": [
{
"id": f"recipient-{index}",
"to": [{"email": email, "type": "to"}],
}
for index, email in enumerate(recipient_emails, start=1)
]
},
"validation_policy": {
"missing_email": "block",
"template_error": "block",
},
"delivery": {"imap_append_sent": {"enabled": False}},
})
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -275,6 +334,107 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
self.assertEqual(archive.namelist(), ["matched.xlsx"])
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
def test_attachment_reuse_action_controls_message_validation(self) -> None:
expected = {
"allow": ("ready", None, 0, 1),
"warn": ("warning", "warn", 1, 0),
"review": ("needs_review", "ask", 1, 0),
"block": ("blocked", "block", 1, 0),
}
for action, (status, behavior, violation_count, allowed_count) in expected.items():
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._attachment_reuse_config(action=action)
result = build_campaign_messages(
config,
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
[status, status],
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["duplicate_file_count"])
self.assertEqual(violation_count, report["violation_file_count"])
self.assertEqual(allowed_count, report["allowed_file_count"])
finding = report["findings"][0]
self.assertEqual("shared.pdf", finding["file_name"])
self.assertNotIn(str(root), str(report))
issues = [
issue
for message in result.report.messages
for issue in message.issues
if issue.code == "duplicate_attachment_reuse"
]
if behavior is None:
self.assertEqual([], issues)
else:
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
self.assertEqual(
{"action": action, "allow_within": "none"},
issues[0].details["policy"],
)
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
cases = (
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
("same_message", ("same@example.org",), True, 2),
)
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
documents = root / "documents"
documents.mkdir()
(documents / "shared.pdf").write_bytes(b"shared")
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
result = build_campaign_messages(
self._attachment_reuse_config(
action="block",
allow_within=allow_within,
recipient_emails=recipients,
duplicate_rules=duplicate_rules,
),
campaign_file=campaign_file,
output_dir=root / "out",
write_eml=False,
)
self.assertEqual(
["ready"] * len(recipients),
[message.validation_status.value for message in result.report.messages],
)
report = result.report.attachment_reuse
self.assertEqual(1, report["allowed_file_count"])
self.assertEqual(0, report["violation_file_count"])
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
config = self._attachment_reuse_config(
action="review",
allow_within="same_recipient",
)
payload = config.model_dump(
mode="json",
by_alias=True,
exclude_none=True,
exclude_defaults=True,
)
payload["server"].pop("profile_capabilities", None)
validate_against_schema(payload)
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
+45
View File
@@ -16,8 +16,10 @@ from govoplan_campaign.backend.persistence.campaigns import (
_verify_storage_keys_absent,
)
from govoplan_campaign.backend.routes.versions import (
_attachment_reuse_audit_evidence,
_campaign_build_recovery_plan,
_residual_file_audit_evidence,
_review_decision_audit_evidence,
)
@@ -56,6 +58,49 @@ def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
}
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
assert _attachment_reuse_audit_evidence({
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
"findings": [{"file_name": "personal.pdf"}],
}) == {
"contract_version": "1",
"policy": {"action": "review", "allow_within": "same_recipient"},
"duplicate_file_count": 4,
"allowed_file_count": 1,
"violation_file_count": 3,
"affected_message_count": 5,
}
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
version = type("Version", (), {
"editor_state": {
"review_send": {
"issue_decisions": [{
"decision": "accept",
"reason": "Approved exception",
"issue_codes": ["duplicate_attachment_reuse"],
"issue_fingerprint": "f" * 64,
"message_sha256": "m" * 64,
}]
}
}
})()
evidence = _review_decision_audit_evidence(version)
assert evidence["count"] == 1
assert evidence["with_reason_count"] == 1
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
assert len(str(evidence["evidence_sha256"])) == 64
assert "Approved exception" not in str(evidence)
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
storage = LocalFilesystemStorageBackend(tmp_path)
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
+30 -2
View File
@@ -78,6 +78,33 @@ def test_complete_review_workflow_documents_each_attention_class() -> None:
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
topic = next(
item
for item in CAMPAIGN_USER_DOCUMENTATION
if item.id == "campaigns.workflow.control-attachment-reuse"
)
rendered = "\n".join(
(
topic.summary,
topic.body,
*topic.metadata["steps"],
topic.metadata["verification"],
)
)
assert "allow" in rendered.lower()
assert "warn" in rendered.lower()
assert "review" in rendered.lower()
assert "block" in rendered.lower()
assert "fingerprint" in rendered.lower()
assert "reason" in rendered.lower()
assert topic.metadata["help_contexts"] == [
"campaign.attachments",
"campaign.attachments.reuse-policy",
]
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") == ()
@@ -418,8 +445,9 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.template",
"campaign.template.content-library",
"campaigns.action.schedule-drafts",
"campaign.attachments",
"campaign.attachments.residual-files",
"campaign.attachments",
"campaign.attachments.reuse-policy",
"campaign.attachments.residual-files",
"campaign.recipients",
"campaign.recipient-data",
"campaign.server-settings",
+37
View File
@@ -92,6 +92,43 @@ def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
assert decisions == []
def test_attachment_reuse_review_requires_reason_and_captures_policy() -> None:
job = _job(
issues=[{
"code": "duplicate_attachment_reuse",
"behavior": "ask",
"source": "attachments:reuse_policy",
"details": {
"file_fingerprint": "f" * 64,
"policy": {"action": "review", "allow_within": "none"},
},
}]
)
with pytest.raises(CampaignPersistenceError, match="require an explicit reason"):
_normalize_review_issue_decisions(
[job],
[],
user_id="reviewer-1",
build_token="build-1",
)
decisions = _normalize_review_issue_decisions(
[job],
[{
"job_id": job.id,
"decision": "accept",
"reason": "The shared statutory notice is intentionally identical.",
}],
user_id="reviewer-1",
build_token="build-1",
)
assert decisions[0]["issue_codes"] == ["duplicate_attachment_reuse"]
assert decisions[0]["reason"] == "The shared statutory notice is intentionally identical."
assert len(decisions[0]["issue_fingerprint"]) == 64
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
return SimpleNamespace(
id="job-1",