security: harden Campaign delivery effects and reconciliation

This commit is contained in:
2026-07-21 17:14:33 +02:00
parent 057e660b17
commit 09c63de813
25 changed files with 2079 additions and 708 deletions

View File

@@ -44,7 +44,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"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": attachments,
@@ -140,7 +143,10 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
"fields": [],
"global_values": {},
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
"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": {

View File

@@ -3,8 +3,14 @@ from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
from govoplan_campaign.backend.reports.campaigns import (
_job_evidence_row,
_latest_by_job_id,
_load_delivery_info,
generate_campaign_report,
)
def _dt() -> datetime:
@@ -87,9 +93,12 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
assert "bundle.zip" in row["attachment_names"]
assert "notice.pdf" in row["attachment_names"]
assert row["latest_smtp_status_code"] == 250
assert row["latest_smtp_response"] == "2.0.0 queued"
assert "latest_smtp_response" not in row
assert "latest_smtp_error_type" not in row
assert "latest_smtp_error_message" not in row
assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent"
assert "latest_imap_error_message" not in row
assert "eml_storage_key" not in row
assert "eml_local_path" not in row
@@ -107,6 +116,45 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
assert latest["job-2"].attempt_number == 2
def test_invalid_legacy_snapshot_never_echoes_validation_details() -> None:
version = SimpleNamespace(
execution_snapshot={
"snapshot_version": "legacy",
"smtp": {"host": "smtp.internal.example", "password": "provider-secret"},
},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
delivery = _load_delivery_info(version, [])
assert delivery["load_error"] == "Execution snapshot is invalid; rebuild the selected version before delivery."
assert "provider-secret" not in repr(delivery)
assert "smtp.internal.example" not in repr(delivery)
def test_aggregate_report_omits_recipient_level_failures_by_default() -> None:
campaign = SimpleNamespace(id="campaign-1")
version = SimpleNamespace(id="version-1")
with (
patch("govoplan_campaign.backend.reports.campaigns._get_campaign", return_value=campaign),
patch("govoplan_campaign.backend.reports.campaigns._selected_version", return_value=version),
patch("govoplan_campaign.backend.reports.campaigns._report_jobs", return_value=[]),
patch(
"govoplan_campaign.backend.reports.campaigns._campaign_report_payload",
return_value={"cards": {}},
) as payload,
):
report = generate_campaign_report(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
)
assert report == {"cards": {}}
assert payload.call_args.kwargs["include_recent_failures"] is False
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()

View File

@@ -0,0 +1,401 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine, text
from govoplan_campaign.backend import router
from govoplan_campaign.backend.db.models import (
CampaignJob,
ImapAppendAttempt,
JobImapStatus,
JobSendStatus,
)
from govoplan_campaign.backend.retention import _apply_eml_retention
from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest
from govoplan_campaign.backend.sending.jobs import (
AppendSentResult,
QueueingError,
_claim_job_for_imap_append,
_record_imap_append_success,
append_sent_for_job,
reconcile_job_outcome,
)
@pytest.mark.parametrize(
("imap_status", "expected_status"),
[
(JobImapStatus.APPENDING.value, "append_in_progress"),
(JobImapStatus.OUTCOME_UNKNOWN.value, JobImapStatus.OUTCOME_UNKNOWN.value),
],
)
def test_in_progress_and_unknown_jobs_never_reinvoke_mail_provider(
imap_status: str,
expected_status: str,
) -> None:
job = SimpleNamespace(
id="job-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=imap_status,
)
session = SimpleNamespace(get=lambda _model, _id: job)
with (
patch("govoplan_campaign.backend.sending.jobs._imap_attempt_count", return_value=1),
patch("govoplan_campaign.backend.sending.jobs.mail_integration") as mail,
):
result = append_sent_for_job(
session, # type: ignore[arg-type]
job_id="job-1",
)
assert result.status == expected_status
mail.assert_not_called()
def test_imap_claim_is_a_single_atomic_state_transition() -> None:
session = MagicMock()
query = session.query.return_value
query.filter.return_value.update.return_value = 1
job = SimpleNamespace(id="job-1")
claim_token = _claim_job_for_imap_append(
session,
job, # type: ignore[arg-type]
)
assert claim_token
changes = query.filter.return_value.update.call_args.args[0]
assert changes[CampaignJob.imap_status] == JobImapStatus.APPENDING.value
assert changes[CampaignJob.imap_claim_token] == claim_token
session.commit.assert_called_once_with()
session.expire_all.assert_called_once_with()
def test_late_provider_acknowledgement_cannot_overwrite_a_changed_claim() -> None:
session = MagicMock()
session.query.return_value.filter.return_value.update.return_value = 0
job = SimpleNamespace(id="job-1")
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
expected = AppendSentResult(
job_id="job-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch(
"govoplan_campaign.backend.sending.jobs._imap_result_after_lost_claim",
return_value=expected,
) as lost_claim,
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
):
result = _record_imap_append_success(
session,
job=job, # type: ignore[arg-type]
attempt=attempt, # type: ignore[arg-type]
claim_token="claim-1",
folder="Sent",
)
assert result is expected
lost_claim.assert_called_once_with(
session,
job_id="job-1",
attempt=attempt,
provider_succeeded=True,
)
files.assert_not_called()
def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.PENDING.value,
)
version = SimpleNamespace(id="version-1")
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="smtp-revision",
imap_transport_revision="imap-revision",
delivery=SimpleNamespace(
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
),
)
attempt = SimpleNamespace(id="attempt-1", attempt_number=1)
class Session:
def get(self, model, _object_id):
return version if model.__name__ == "CampaignVersion" else job
class Mail:
def append_campaign_message_to_sent(self, *_args, **_kwargs):
return SimpleNamespace(folder="Sent")
expected = AppendSentResult(
job_id="job-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
session = Session()
with (
patch("govoplan_campaign.backend.sending.jobs.ensure_execution_snapshot", return_value=snapshot),
patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"),
patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"),
patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt),
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch(
"govoplan_campaign.backend.sending.jobs._record_imap_append_success",
side_effect=OSError("database unavailable"),
),
patch(
"govoplan_campaign.backend.sending.jobs._mark_imap_append_outcome_unknown_after_effect",
return_value=expected,
) as mark_unknown,
):
result = append_sent_for_job(
session, # type: ignore[arg-type]
job_id="job-1",
)
assert result is expected
assert "Automatic retry is stopped" in mark_unknown.call_args.kwargs["reason"]
def test_attempt_numbers_are_unique_per_job_in_the_model_contract() -> None:
constraints = {
constraint.name
for constraint in ImapAppendAttempt.__table__.constraints
}
assert "uq_imap_append_attempts_job_attempt" in constraints
def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions.3c4d5e6f8192_v019_imap_append_claim"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(
text(
"CREATE TABLE imap_append_attempts ("
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL, "
"attempt_number INTEGER NOT NULL, created_at DATETIME NOT NULL)"
)
)
connection.execute(
text(
"INSERT INTO imap_append_attempts "
"(id, job_id, attempt_number, created_at) VALUES "
"('a2', 'job-1', 1, '2026-01-02'), "
"('a1', 'job-1', 1, '2026-01-01'), "
"('b1', 'job-2', 7, '2026-01-01')"
)
)
with patch.object(migration.op, "get_bind", return_value=connection):
migration._renumber_attempts()
rows = connection.execute(
text(
"SELECT id, job_id, attempt_number FROM imap_append_attempts "
"ORDER BY job_id, attempt_number"
)
).all()
assert rows == [
("a1", "job-1", 1),
("a2", "job-1", 2),
("b1", "job-2", 1),
]
def test_imap_reconciliation_requires_an_evidence_note() -> None:
with pytest.raises(ValueError, match="evidence note"):
CampaignResolveOutcomeRequest(
decision="imap_not_appended",
note=" ",
)
@pytest.mark.parametrize(
("decision", "expected_status", "attempt_status"),
[
(
"imap_appended",
JobImapStatus.APPENDED.value,
"reconciled_imap_appended",
),
(
"imap_not_appended",
JobImapStatus.FAILED.value,
"reconciled_imap_not_appended",
),
],
)
def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryable(
decision: str,
expected_status: str,
attempt_status: str,
) -> None:
campaign = SimpleNamespace(id="campaign-1")
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
imap_claimed_at=datetime.now(timezone.utc),
imap_claim_token="claim-1",
last_error="unknown",
)
attempt = SimpleNamespace(
id="attempt-1",
status=JobImapStatus.OUTCOME_UNKNOWN.value,
error_message="unknown",
)
session = MagicMock()
session.get.return_value = job
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
with (
patch(
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
return_value=campaign,
),
patch("govoplan_campaign.backend.sending.jobs.files_integration") as files,
):
result = reconcile_job_outcome(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
decision=decision,
note="Mailbox UID evidence checked by operator 42.",
commit=False,
)
assert result["channel"] == "imap"
assert job.imap_status == expected_status
assert job.imap_claimed_at is None
assert job.imap_claim_token is None
assert attempt.status == attempt_status
assert attempt.error_message == "Mailbox UID evidence checked by operator 42."
assert attempt in session.add.call_args_list[0].args
session.flush.assert_called_once_with()
session.commit.assert_not_called()
if decision == "imap_appended":
files().mark_job_attachment_uses_sent.assert_called_once_with(session, job)
else:
files().mark_job_attachment_uses_sent.assert_not_called()
def test_imap_reconciliation_rejects_a_retryable_or_completed_state() -> None:
campaign = SimpleNamespace(id="campaign-1")
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.FAILED.value,
)
session = MagicMock()
session.get.return_value = job
with patch(
"govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant",
return_value=campaign,
):
with pytest.raises(QueueingError, match="does not require reconciliation"):
reconcile_job_outcome(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
job_id="job-1",
decision="imap_not_appended",
note="Checked mailbox.",
)
@pytest.mark.parametrize(
("decision", "note"),
[
("smtp_accepted", None),
("imap_appended", "Mailbox evidence checked."),
],
)
def test_reconciliation_rolls_back_state_when_audit_fails(
decision: str,
note: str | None,
) -> None:
payload = CampaignResolveOutcomeRequest(decision=decision, note=note) # type: ignore[arg-type]
principal = SimpleNamespace(tenant_id="tenant-1")
session = MagicMock()
def mutate_without_commit(*_args, **kwargs):
assert kwargs["commit"] is False
session.flush()
return {"decision": decision, "job_id": "job-1"}
with (
patch("govoplan_campaign.backend.router._get_campaign_for_principal"),
patch("govoplan_campaign.backend.router._require_permission"),
patch(
"govoplan_campaign.backend.router.reconcile_job_outcome",
side_effect=mutate_without_commit,
),
patch(
"govoplan_campaign.backend.router.audit_from_principal",
side_effect=RuntimeError("audit unavailable"),
),
):
with pytest.raises(RuntimeError, match="audit unavailable"):
router.resolve_campaign_job_outcome(
"campaign-1",
"job-1",
payload,
session=session,
principal=principal, # type: ignore[arg-type]
)
session.commit.assert_not_called()
session.rollback.assert_called_once_with()
def test_retention_preserves_eml_for_unknown_imap_outcome(tmp_path: Path) -> None:
eml_path = tmp_path / "message.eml"
eml_path.write_bytes(b"message")
job = SimpleNamespace(
campaign_id="campaign-1",
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
queue_status="draft",
send_status=JobSendStatus.SMTP_ACCEPTED.value,
imap_status=JobImapStatus.OUTCOME_UNKNOWN.value,
eml_local_path=str(eml_path),
eml_storage_key=None,
)
query = MagicMock()
query.filter.return_value.order_by.return_value.all.return_value = [job]
session = MagicMock()
session.query.return_value = query
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=datetime.now(timezone.utc),
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_not_final"] == 1
assert eml_path.exists()
session.add.assert_not_called()

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
from govoplan_campaign.backend.persistence.versions import update_campaign_version
from govoplan_campaign.backend.integrations import MailCampaignIntegration
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
return {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": server or {},
"recipients": {"from": [{"email": "sender@example.test"}]},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {"inline": []},
}
def test_campaign_mail_contract_accepts_only_a_stable_profile_reference() -> None:
raw = _campaign_json({"mail_profile_id": " profile-1 "})
assert_campaign_uses_mail_profile_reference(raw)
assert campaign_mail_profile_id(raw) == "profile-1"
assert campaign_mail_profile_boundary_violations(raw) == ()
def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
from govoplan_campaign.backend.manifest import get_manifest
manifest = get_manifest()
topics = {topic.id: topic for topic in manifest.documentation}
workflow = topics["campaigns.mail-profile-user-journey"]
assert workflow.metadata["kind"] == "workflow"
assert workflow.metadata["route"] == "/campaigns/{campaign_id}/mail"
assert workflow.metadata["prerequisites"]
assert workflow.metadata["steps"]
assert workflow.metadata["outcome"]
assert workflow.metadata["verification"]
assert "campaigns.mail-profile-governance" in workflow.metadata["related_topic_ids"]
assert topics["campaigns.mail-profile-governance"].metadata["kind"] == "reference"
assert topics["campaigns.mail-profile-operations"].metadata["kind"] == "reference"
@pytest.mark.parametrize("legacy_key", ["smtp", "imap", "credentials", "inherit_smtp_credentials", "profile_id"])
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"):
assert_campaign_uses_mail_profile_reference(raw)
def test_persisted_schema_rejects_inline_transport_even_without_a_secret() -> None:
with pytest.raises(CampaignSchemaError, match="Additional properties are not allowed"):
validate_against_schema(_campaign_json({"smtp": {"host": "smtp.example.test"}}))
def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None:
integration = SimpleNamespace(campaign_profile_delivery_summary=lambda *_args, **_kwargs: pytest.fail("must not resolve"))
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local SMTP/IMAP settings"):
load_campaign_config_from_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
raw_json=_campaign_json({"smtp": {"password": "secret"}}),
)
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"})
def summary(_session, **kwargs):
assert kwargs["profile_id"] == "profile-1"
return {
"mail_profile_id": "profile-1",
"smtp_available": True,
"imap_available": False,
"smtp_transport_revision": "opaque-smtp",
"imap_transport_revision": None,
# Even a broken/malicious provider cannot inject extra material into
# Campaign's strict in-memory ServerConfig.
"host": "smtp.example.test",
"password": "secret",
}
integration = SimpleNamespace(campaign_profile_delivery_summary=summary)
with patch("govoplan_campaign.backend.persistence.campaigns.mail_integration", return_value=integration):
config = load_campaign_config_from_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
raw_json=raw,
)
assert raw["server"] == {"mail_profile_id": "profile-1"}
assert config.server.mail_profile_id == "profile-1"
assert config.server.profile_capabilities.smtp_available is True
assert config.server.profile_capabilities.imap_available is False
assert "smtp.example.test" not in repr(config.server)
assert "secret" not in repr(config.server)
def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_material() -> None:
raw = _campaign_json({"mail_profile_id": "profile-1"})
version = SimpleNamespace(id="version-1", raw_json=raw)
payload, _digest = create_execution_snapshot(
version, # type: ignore[arg-type]
mail_profile_id="profile-1",
smtp_transport_revision="opaque-smtp-evidence",
imap_transport_revision="opaque-imap-evidence",
delivery=DeliveryConfig(),
)
assert payload["snapshot_version"] == "5"
assert payload["mail_profile_id"] == "profile-1"
assert "smtp" not in payload
assert "imap" not in payload
assert payload["smtp_transport_revision"] == "opaque-smtp-evidence"
assert payload["imap_transport_revision"] == "opaque-imap-evidence"
def test_legacy_execution_snapshot_is_preserved_but_fails_closed() -> None:
version = SimpleNamespace(
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
execution_snapshot={"snapshot_version": "3", "smtp": {"host": "legacy.example.test"}},
execution_snapshot_hash=None,
)
with patch(
"govoplan_campaign.backend.sending.execution.files_integration",
return_value=SimpleNamespace(available=False),
):
with pytest.raises(ExecutionSnapshotError, match="preserved for audit only"):
ensure_execution_snapshot(object(), version) # type: ignore[arg-type]
assert version.execution_snapshot["smtp"]["host"] == "legacy.example.test"
def test_campaign_mail_adapter_does_not_expose_raw_transport_helpers() -> None:
integration = MailCampaignIntegration(SimpleNamespace())
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
"materialize_campaign_mail_profile_config",
):
assert not hasattr(integration, name)
def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> None:
legacy_raw = _campaign_json({"smtp": {"host": "smtp.example.test", "password": "secret"}})
version = SimpleNamespace(id="version-1", campaign_id="campaign-1", raw_json=legacy_raw)
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
with (
patch("govoplan_campaign.backend.persistence.versions.get_campaign_version_for_tenant", return_value=version),
patch("govoplan_campaign.backend.persistence.versions._require_campaign", return_value=campaign),
patch("govoplan_campaign.backend.persistence.versions.ensure_current_working_version"),
patch("govoplan_campaign.backend.persistence.versions.is_version_locked", return_value=False),
):
with pytest.raises(CampaignPersistenceError, match="explicitly save the migration"):
update_campaign_version(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
)
assert version.raw_json is legacy_raw
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
user=SimpleNamespace(id="user-1"),
)
campaign = SimpleNamespace(id="campaign-1")
source = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(router, "_require_permission"),
patch.object(router, "_get_version_for_tenant", return_value=source),
patch.object(router, "has_scope", return_value=False),
patch.object(router, "fork_campaign_version_for_edit") as fork,
):
with pytest.raises(HTTPException) as captured:
router.fork_version_for_edit(
"campaign-1",
"version-1",
CampaignVersionUpdateRequest(),
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
fork.assert_not_called()

View File

@@ -80,6 +80,30 @@ class CampaignPartialValidationTests(unittest.TestCase):
class CampaignSemanticValidationTests(unittest.TestCase):
def test_send_mode_requires_campaign_owned_sender_for_each_inline_entry(self) -> None:
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": {
"mail_profile_id": "profile-1",
"profile_capabilities": {"smtp_available": True},
},
"recipients": {"allow_individual_from": True},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {
"inline": [
{"id": "ready", "from": [{"email": "sender@example.local"}]},
{"id": "missing"},
]
},
})
report = validate_campaign_config(config)
missing_sender = [issue for issue in report.issues if issue.code == "missing_sender"]
self.assertEqual(1, len(missing_sender))
self.assertEqual("/entries/inline/1/from", missing_sender[0].path)
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -139,8 +163,7 @@ class CampaignSemanticValidationTests(unittest.TestCase):
self.assertIn("unknown_global_value", codes)
self.assertIn("zip_global_password_value_missing", codes)
self.assertIn("zip_archive_unknown", codes)
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
self.assertIn("missing_smtp_config", codes)
self.assertIn("missing_mail_profile", codes)
self.assertIn("unknown_mapping_target", codes)
self.assertIn("mapping_target_not_overridable", codes)
self.assertIn("mapping_columns_missing", codes)

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from govoplan_campaign.backend import router
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
from govoplan_campaign.backend.schemas import ReportEmailRequest
def test_report_email_recipient_schema_normalizes_and_has_safe_attachment_default() -> None:
request = ReportEmailRequest.model_validate({
"to": [" First@Example.test ", "first@example.test", "second@example.test"],
})
assert request.to == ["First@Example.test", "second@example.test"]
assert request.attach_jobs_csv is False
@pytest.mark.parametrize(
"recipients",
[
[],
[f"recipient-{index}@example.test" for index in range(51)],
["a" * 310 + "@example.test"],
["victim@example.test\r\nBcc: attacker@example.test"],
["missing-at.example.test"],
["@example.test"],
["local@"],
["local @example.test"],
["one@two@example.test"],
["victim@example.test,Bcc:attacker"],
["Display<a@example.test>"],
],
)
def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list[str]) -> None:
with pytest.raises(ValidationError):
ReportEmailRequest.model_validate({"to": recipients})
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
principal = SimpleNamespace(tenant_id="tenant-1")
with (
patch.object(
router,
"_get_campaign_for_principal",
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
),
patch.object(
router,
"_require_permission",
side_effect=HTTPException(status_code=403, detail="missing export"),
) as require_permission,
patch.object(router, "send_campaign_report_email") as send_report,
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
require_permission.assert_called_once_with(principal, "campaigns:recipient:export")
send_report.assert_not_called()
def test_report_email_requires_permission_to_use_selected_mail_profile() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
principal = SimpleNamespace(tenant_id="tenant-1")
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
)
session = SimpleNamespace(get=lambda _model, _id: version)
with (
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
patch.object(
router,
"_require_mail_profile_use_if_needed",
side_effect=HTTPException(status_code=403, detail="missing profile use"),
) as require_profile_use,
patch.object(router, "send_campaign_report_email") as send_report,
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=session, # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 403
require_profile_use.assert_called_once_with(principal, version.raw_json)
send_report.assert_not_called()
def test_unexpected_report_failure_does_not_leak_internal_details() -> None:
payload = ReportEmailRequest(to=["recipient@example.test"])
principal = SimpleNamespace(tenant_id="tenant-1")
with (
patch.object(
router,
"_get_campaign_for_principal",
return_value=SimpleNamespace(id="campaign-1", current_version_id=None),
),
patch.object(
router,
"send_campaign_report_email",
side_effect=RuntimeError("smtp.internal.example provider-secret"),
),
):
with pytest.raises(HTTPException) as captured:
router.email_campaign_report(
"campaign-1",
payload,
session=object(), # type: ignore[arg-type]
principal=principal, # type: ignore[arg-type]
)
assert captured.value.status_code == 500
assert captured.value.detail == "Campaign report email could not be completed."
def test_report_send_fails_closed_until_durable_mail_outbox_exists() -> None:
campaign = SimpleNamespace(
id="campaign-1",
tenant_id="tenant-1",
name="Campaign",
external_id="campaign-1",
)
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
raw_json={"server": {"mail_profile_id": "profile-1"}},
execution_snapshot={"snapshot_version": "5"},
)
config = SimpleNamespace(
server=SimpleNamespace(profile_capabilities=SimpleNamespace(smtp_available=True)),
)
snapshot = SimpleNamespace(smtp_transport_revision="frozen-build-revision")
class Session:
def get(self, _model, _id):
return campaign
with (
patch("govoplan_campaign.backend.reports.emailing._selected_version", return_value=version),
patch("govoplan_campaign.backend.reports.emailing._load_config", return_value=config),
patch("govoplan_campaign.backend.reports.emailing.ensure_execution_snapshot", return_value=snapshot),
patch("govoplan_campaign.backend.reports.emailing.generate_campaign_report") as generate_report,
):
with pytest.raises(CampaignReportEmailError, match="durable, idempotent Mail-owned outbox"):
send_campaign_report_email(
Session(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
to=["recipient@example.test"],
)
generate_report.assert_not_called()

View File

@@ -40,7 +40,7 @@ def _job() -> SimpleNamespace:
eml_storage_key="campaign/job-1.eml",
claim_token="job-claim-secret",
attempt_count=1,
last_error=None,
last_error="smtp.internal.example /srv/private provider-secret",
queued_at=_now(),
claimed_at=_now(),
smtp_started_at=_now(),
@@ -67,9 +67,9 @@ def _smtp_attempt() -> SimpleNamespace:
status="started",
claim_token="attempt-claim-secret",
smtp_status_code=None,
smtp_response=None,
error_type=None,
error_message=None,
smtp_response="smtp.internal.example provider-secret",
error_type="/srv/private/ProviderError",
error_message="credential=provider-secret",
started_at=_now(),
finished_at=None,
)
@@ -82,7 +82,7 @@ def _imap_attempt() -> SimpleNamespace:
status="claimed",
claim_token="imap-claim-secret",
folder="Sent",
error_message=None,
error_message="imap.internal.example /srv/private provider-secret",
created_at=_now(),
updated_at=_now(),
)
@@ -148,6 +148,7 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
"campaign": {"title": "Public"},
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
"server": {
"mail_profile_id": "profile-1",
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
"credentials": {
@@ -176,14 +177,7 @@ def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> No
assert detail["raw_json"] == {
"campaign": {"title": "Public"},
"files": [{"filename": "public.pdf"}],
"server": {
"smtp": {"host": "smtp.example.invalid"},
"imap": {"host": "imap.example.invalid"},
"credentials": {
"smtp": {"username": "sender"},
"imap": {"username": "archive"},
},
},
"server": {"mail_profile_id": "profile-1"},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
@@ -209,6 +203,14 @@ def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0]
assert "smtp_response" not in attempts["smtp"][0]
assert "error_type" not in attempts["smtp"][0]
assert "error_message" not in attempts["smtp"][0]
assert "error_message" not in attempts["imap"][0]
ordinary = repr({"job": job_payload, "attempts": attempts})
assert "internal.example" not in ordinary
assert "/srv/private" not in ordinary
assert "provider-secret" not in ordinary
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
@@ -223,6 +225,9 @@ def test_operator_diagnostics_include_claim_and_storage_details() -> None:
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
assert "smtp.internal.example" in diagnostics["attempts"]["smtp"][0]["smtp_response"]
assert "imap.internal.example" in diagnostics["attempts"]["imap"][0]["error_message"]
assert "provider-secret" in diagnostics["worker_claim"]["last_error"]
def test_diagnostics_permission_is_operator_only_by_default() -> None:

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_campaign.backend.db.models import (
JobBuildStatus,
@@ -10,8 +11,10 @@ from govoplan_campaign.backend.db.models import (
JobValidationStatus,
)
from govoplan_campaign.backend.sending.jobs import (
SendJobResult,
_queue_validation_statuses,
_select_campaign_jobs_for_queue,
_send_claimed_campaign_job,
)
@@ -115,6 +118,78 @@ class CampaignQueueSelectionTests(unittest.TestCase):
self.assertEqual(warning.send_status, JobSendStatus.NOT_QUEUED.value)
self.assertEqual(session.added, [])
def test_post_smtp_persistence_failure_is_outcome_unknown_not_retryable_failure(self):
job = SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
imap_status="not_requested",
resolved_recipients={"from": {"email": "sender@example.test"}},
)
snapshot = SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="frozen",
delivery=SimpleNamespace(
rate_limit=SimpleNamespace(messages_per_minute=60),
),
)
context = SimpleNamespace(
snapshot=snapshot,
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
)
current = SimpleNamespace(id="job-1")
class Session:
def __init__(self) -> None:
self.rolled_back = False
def rollback(self) -> None:
self.rolled_back = True
def get(self, _model, _id):
return current
class Mail:
def wait_for_rate_limit(self, **_kwargs):
return None
def send_campaign_email_bytes(self, *_args, **_kwargs):
return SimpleNamespace(accepted_count=1)
session = Session()
expected = SendJobResult(
job_id="job-1",
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=1,
)
with (
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()),
patch(
"govoplan_campaign.backend.sending.jobs._record_smtp_send_success",
side_effect=OSError("storage unavailable"),
),
patch(
"govoplan_campaign.backend.sending.jobs.mark_job_outcome_unknown",
return_value=expected,
) as mark_unknown,
):
result = _send_claimed_campaign_job(
session, # type: ignore[arg-type]
job=job, # type: ignore[arg-type]
claim_token="claim-1",
context=context, # type: ignore[arg-type]
use_rate_limit=False,
enqueue_imap_task=False,
)
self.assertIs(result, expected)
self.assertTrue(session.rolled_back)
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
if __name__ == "__main__":
unittest.main()