security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
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()
|
||||
222
tests/test_mail_profile_boundary.py
Normal file
222
tests/test_mail_profile_boundary.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
169
tests/test_report_email_security.py
Normal file
169
tests/test_report_email_security.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user