feat(campaign): govern attachment reuse
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user