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

@@ -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()