security: harden Campaign delivery effects and reconciliation
This commit is contained in:
401
tests/test_imap_append_idempotency.py
Normal file
401
tests/test_imap_append_idempotency.py
Normal 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()
|
||||
Reference in New Issue
Block a user