Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
+37 -2
View File
@@ -159,9 +159,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
cases = {
"block": ("build_failed", "blocked", 0, "block", False),
"ask": ("built", "needs_review", 0, "ask", True),
"drop": ("built", "needs_review", 0, "ask", True),
"drop": ("built", "excluded", 0, "drop", True),
"warn": ("built", "warning", 1, "warn", True),
"continue": ("built", "warning", 1, None, True),
"continue": ("built", "ready", 1, None, True),
}
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
with self.subTest(behavior=behavior):
@@ -257,6 +257,41 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
)
self.assertEqual(issue.behavior, "warn")
def test_explicit_optional_empty_rule_is_information_unless_hard_blocked(self) -> None:
for policy, expected in (("warn", "ready"), ("ask", "ready"), ("block", "blocked")):
with self.subTest(policy=policy), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._no_attachment_config(behavior="continue", configure_missing_rule=True)
config.validation_policy.missing_optional_attachment = policy
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
message = result.report.messages[0]
self.assertEqual(message.validation_status.value, expected)
issue = next(item for item in message.issues if item.code == "missing_optional_attachment")
self.assertEqual(issue.behavior, "block" if policy == "block" else "continue")
self.assertEqual(issue.severity, "error" if policy == "block" else "info")
self.assertEqual(message.attachments[0].matches, [])
def test_deliberate_rule_drop_is_excluded_but_cannot_override_required_block(self) -> None:
for required, expected in ((False, "excluded"), (True, "blocked")):
with self.subTest(required=required), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
campaign_file = root / "campaign.json"
campaign_file.write_text("{}", encoding="utf-8")
config = self._no_attachment_config(behavior="continue", configure_missing_rule=True)
rule = config.attachments.global_[0]
rule.missing_behavior = "drop"
rule.required = required
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
message = result.report.messages[0]
self.assertEqual(message.validation_status.value, expected)
self.assertEqual(result.report.queueable_count, 0)
if not required:
self.assertEqual(message.send_status.value, "skipped")
self.assertEqual(message.imap_status.value, "skipped")
self.assertEqual(message.issues[0].behavior, "drop")
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import subprocess
import sys
import unittest
from pathlib import Path
_IMPORT_PROGRAM = """
import importlib
import socket
import sys
sys.path.insert(0, sys.argv[1])
def deny_network(*args, **kwargs):
raise AssertionError("Campaign imports must not connect to external services")
class NoNetworkSocket(socket.socket):
connect = deny_network
connect_ex = deny_network
socket.create_connection = deny_network
socket.socket = NoNetworkSocket
importlib.import_module(sys.argv[2])
from govoplan_campaign.backend.campaign import (
CampaignConfig, SemanticIssue, SemanticReport,
load_campaign_config, load_campaign_json, validate_campaign_config,
)
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
assert all(callable(value) for value in (
CampaignConfig, SemanticIssue, SemanticReport,
load_campaign_config, load_campaign_json, validate_campaign_config,
resolve_campaign_attachments,
))
"""
class CampaignImportOrderTests(unittest.TestCase):
def test_model_validation_and_attachment_entry_points_import_independently(self):
source_root = Path(__file__).resolve().parents[1] / "src"
for first_module in (
"govoplan_campaign.backend.attachments.resolver",
"govoplan_campaign.backend.campaign.validation",
"govoplan_campaign.backend.campaign.entries",
):
with self.subTest(first_module=first_module):
completed = subprocess.run(
[sys.executable, "-I", "-c", _IMPORT_PROGRAM, str(source_root), first_module],
capture_output=True,
text=True,
check=False,
timeout=30,
)
self.assertEqual(0, completed.returncode, completed.stderr or completed.stdout)
if __name__ == "__main__":
unittest.main()
@@ -160,6 +160,42 @@ class CampaignOptimisticConcurrencyTests(unittest.TestCase):
assert current is not None
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
def test_metadata_save_does_not_erase_recorded_review(self) -> None:
review = {
"build_token": "server-build-token",
"inspection_complete": True,
"reviewed_message_keys": ["recipient-1"],
"issue_decisions": [],
"updated_at": "2026-07-21T00:00:00+00:00",
"updated_by_user_id": "reviewer-1",
}
with self.SessionLocal() as session:
version = session.get(CampaignVersion, "version-1")
assert version is not None
version.editor_state = {
"created_from": "minimal_campaign",
"review_send": review,
}
session.commit()
saved = update_campaign_version(
session,
tenant_id="tenant-1",
campaign_id="campaign-1",
version_id="version-1",
editor_state={"opt_ins": {"inline_guidance": False}},
expected_revision=version.edit_revision,
autosave=True,
)
self.assertEqual(saved.editor_state["review_send"], review)
with self.SessionLocal() as verification:
current = verification.get(CampaignVersion, "version-1")
assert current is not None
self.assertEqual(current.editor_state, {
"opt_ins": {"inline_guidance": False},
"review_send": review,
})
if __name__ == "__main__":
unittest.main()
+55 -34
View File
@@ -223,8 +223,9 @@ def test_queue_projection_fails_closed_if_no_task_was_published() -> None:
)
@pytest.mark.parametrize("duplicate_mutates", [False, True])
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
monkeypatch,
monkeypatch, duplicate_mutates,
) -> None:
first_worker = mock.Mock()
first_worker.received_task_ids.return_value = (TASK_ID,)
@@ -232,22 +233,29 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
replacement_worker.received_task_ids.return_value = (TASK_ID,)
workers = iter([first_worker, replacement_worker])
endpoint = _Endpoint()
durable_states = iter(
[
{
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"smtp_in_progress": 1},
"unfinished_attempt_count": 1,
},
{
"job_count": 1,
"send_status_counts": {"outcome_unknown": 1},
"attempt_status_counts": {"outcome_unknown": 1},
"unfinished_attempt_count": 0,
},
]
)
durable_states = [
{
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"smtp_in_progress": 1},
"unfinished_attempt_count": 1,
},
{
"job_count": 1,
"send_status_counts": {"sending": 1},
"attempt_status_counts": {"smtp_in_progress": 1},
"unfinished_attempt_count": 1,
},
{
"job_count": 1,
"send_status_counts": {"outcome_unknown": 1},
"attempt_status_counts": {"outcome_unknown": 1},
"unfinished_attempt_count": 0,
},
]
if duplicate_mutates:
durable_states[1] = durable_states[2]
durable_states = iter(durable_states)
prepared = SimpleNamespace(
campaign_id="campaign-internal",
version_id="version-internal",
@@ -277,24 +285,34 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
"_wait_for_broker_drained",
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
)
recover_claim = mock.Mock(return_value={"explicit_fenced_recovery": True})
evidence = runner.execute_redelivery_scenario(
_Client(),
{"Authorization": "not-retained"},
fixture_path=FIXTURE_PATH,
settings=_settings(),
endpoint=endpoint,
redis_url="redis://127.0.0.1:36379/0",
runtime_root=Path("/not-used"),
snapshot_probe=lambda _version_id: ({}, {}),
audit_probe=lambda _campaign_id, _version_id: {
"campaign.created": 1,
"campaign.validated": 1,
"campaign.messages_built": 1,
"campaign.queued": 1,
},
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
)
def execute():
return runner.execute_redelivery_scenario(
_Client(),
{"Authorization": "not-retained"},
fixture_path=FIXTURE_PATH,
settings=_settings(),
endpoint=endpoint,
redis_url="redis://127.0.0.1:36379/0",
runtime_root=Path("/not-used"),
snapshot_probe=lambda _version_id: ({}, {}),
audit_probe=lambda _campaign_id, _version_id: {
"campaign.created": 1,
"campaign.validated": 1,
"campaign.messages_built": 1,
"campaign.queued": 1,
},
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
recover_claim=recover_claim,
)
if duplicate_mutates:
with pytest.raises(runner.AcceptanceError, match="without stopped-runtime proof"):
execute()
recover_claim.assert_not_called()
return
evidence = execute()
assert evidence["broker"] == {
"transport": "redis",
@@ -310,5 +328,8 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
assert evidence["recovered_durable_state"]["send_status_counts"] == {
"outcome_unknown": 1
}
assert evidence["redelivered_durable_state"] == evidence["interrupted_durable_state"]
assert evidence["supervision"]["duplicate_task_left_sending_unchanged"] is True
recover_claim.assert_called_once_with("campaign-internal", "version-internal", first_worker.process)
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
assert endpoint.release_count >= 1
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import ValidationError
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.db.session import get_session
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
from govoplan_core.tenancy.scope import Tenant
from govoplan_campaign.backend.delivery_policy import SYNCHRONOUS_SEND_MAX_ENV, effective_synchronous_send_policy
from govoplan_campaign.backend.routes import delivery_settings as routes
from govoplan_campaign.backend.router import router as campaign_router
def principal(*scopes, tenant="tenant-a"):
actor = SimpleNamespace(id="admin-1")
return ApiPrincipal(principal=PrincipalRef(account_id=actor.id, membership_id=actor.id, tenant_id=tenant, scopes=frozenset(scopes)), user=actor, account=actor)
@pytest.fixture
def policy(tmp_path, monkeypatch):
monkeypatch.delenv(SYNCHRONOUS_SEND_MAX_ENV, raising=False)
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'policy.db'}")
for table in (SystemSettings.__table__, Tenant.__table__, ChangeSequenceEntry.__table__):
table.create(engine)
with Session(engine) as session:
session.add_all([
SystemSettings(id="global", settings={"unrelated": {"enabled": True}}),
Tenant(id="tenant-a", slug="a", name="A", settings={"unrelated": "preserve"}),
Tenant(id="tenant-b", slug="b", name="B", settings={}),
])
session.commit()
with patch.object(routes, "audit_from_principal", autospec=True) as audit:
yield SimpleNamespace(session=session, engine=engine, audit=audit,
admin=principal("system:settings:read", "system:settings:write", "admin:policies:read", "admin:policies:write"))
engine.dispose()
def state(policy, scope="system", actor=None):
return routes.read_delivery_policy(scope, session=policy.session, principal=actor or policy.admin)
def save(policy, value, scope="system", revision=None, actor=None):
payload = routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision=revision or state(policy, scope)["revision"])
return routes.update_delivery_policy(scope, payload, session=policy.session, principal=actor or policy.admin)
def test_system_admin_can_raise_implicit_default_and_save_is_durable_audited_and_scoped(policy):
before = state(policy)
assert before["effective_max_recipients"] == 25 and before["max_configurable_recipients"] == 500
result = save(policy, 200)
assert result["effective_max_recipients"] == 200 and result["revision"] != before["revision"]
with Session(policy.engine) as fresh:
assert effective_synchronous_send_policy(fresh, tenant_id="tenant-a", environ={}).max_recipient_jobs == 200
system = fresh.get(SystemSettings, "global")
assert system.settings["unrelated"] == {"enabled": True}
history = system.settings["_configuration_control"]["history"]
assert len(history) == 1 and history[0]["key"] == "campaign_delivery_policy.system"
assert history[0]["before"]["synchronous_send_max_recipients"] is None
assert history[0]["after"]["synchronous_send_max_recipients"] == 200
assert history[0]["actor_user_id"] == "admin-1"
assert policy.audit.call_args.kwargs["commit"] is False
assert policy.audit.call_args.kwargs["scope"] == "system"
def test_tenant_can_only_narrow_and_reset_inherits_without_affecting_other_tenant(policy):
save(policy, 200)
with pytest.raises(HTTPException) as failure:
save(policy, 201, "tenant")
assert failure.value.status_code == 422
save(policy, 183, "tenant")
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-a").max_recipient_jobs == 183
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-b").max_recipient_jobs == 200
assert policy.session.get(Tenant, "tenant-a").settings["unrelated"] == "preserve"
assert save(policy, None, "tenant")["effective_max_recipients"] == 200
assert save(policy, None)["effective_max_recipients"] == 25
def test_explicit_deployment_ceiling_remains_authoritative_and_zero_disables(policy, monkeypatch):
save(policy, 200)
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, "40")
assert state(policy)["effective_max_recipients"] == 40
with pytest.raises(HTTPException) as failure:
save(policy, 41)
assert failure.value.status_code == 422
assert save(policy, None)["effective_max_recipients"] == 40
assert save(policy, 0)["effective_max_recipients"] == 0
assert state(policy, "tenant")["max_configurable_recipients"] == 0
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
def test_blank_deployment_value_never_turns_default_into_absolute_maximum(policy, monkeypatch, blank):
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, blank)
assert state(policy)["effective_max_recipients"] == 25
assert state(policy)["deployment_ceiling_explicit"] is False
save(policy, 200)
assert state(policy)["effective_max_recipients"] == 200
save(policy, 183, "tenant")
assert state(policy, "tenant")["effective_max_recipients"] == 183
def test_stale_parent_own_or_aba_revision_never_overwrites_policy(policy):
initial = state(policy)
tenant = state(policy, "tenant")
save(policy, 200)
for scope, revision in (("system", initial["revision"]), ("tenant", tenant["revision"])):
with pytest.raises(HTTPException) as failure:
save(policy, 10, scope, revision=revision)
assert failure.value.status_code == 409
save(policy, None)
with pytest.raises(HTTPException) as failure:
save(policy, 30, revision=initial["revision"])
assert failure.value.status_code == 409
@pytest.mark.parametrize("scope,scopes,operation", [
("system", ("admin:policies:read", "admin:policies:write"), "read"),
("system", ("admin:policies:write",), "write"),
("tenant", ("system:settings:read", "system:settings:write"), "read"),
("tenant", ("system:settings:write",), "write"),
("system", ("system:settings:read",), "write"),
("tenant", ("admin:policies:read",), "write"),
])
def test_permissions_cannot_cross_scope_or_use_read_permission_to_write(policy, scope, scopes, operation):
actor = principal(*scopes)
with pytest.raises(HTTPException) as failure:
if operation == "read": state(policy, scope, actor=actor)
else: save(policy, 10, scope, actor=actor)
assert failure.value.status_code == 403
assert "_configuration_control" not in policy.session.get(SystemSettings, "global").settings
@pytest.mark.parametrize("value", [True, 1.5, "20", -1, 501])
def test_schema_rejects_coercions_and_unbounded_values(value):
with pytest.raises(ValidationError):
routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision="a" * 64)
def test_route_rolls_back_configuration_and_history_if_audit_fails(policy):
policy.audit.side_effect = RuntimeError("Audit unavailable")
with pytest.raises(RuntimeError, match="Audit unavailable"):
save(policy, 200)
policy.session.expire_all()
assert policy.session.get(SystemSettings, "global").settings == {"unrelated": {"enabled": True}}
def test_configuration_catalog_supports_only_known_scope_keys_and_scopes():
for scope, permission in (("system", "system:settings:write"), ("tenant", "admin:policies:write")):
key = f"campaign_delivery_policy.{scope}"
field = classify_configuration_field(key)
assert field.owner_module == "campaigns" and field.rollback_history_required
assert plan_configuration_change(key, actor_scopes=(permission,), value={"synchronous_send_max_recipients": 200}).allowed
assert not plan_configuration_change(key, actor_scopes=(), value={"synchronous_send_max_recipients": 200}).allowed
assert classify_configuration_field("campaign_delivery_policy.unknown") is None
def test_registered_http_routes_validate_scope_authorization_payload_and_revision(policy):
app = FastAPI()
app.include_router(campaign_router, prefix="/api/v1")
app.dependency_overrides[get_session] = lambda: policy.session
actor = [policy.admin]
app.dependency_overrides[get_api_principal] = lambda: actor[0]
with TestClient(app) as client:
path = "/api/v1/campaigns/settings/delivery-policy/system"
response = client.get(path)
assert response.status_code == 200
payload = {"synchronous_send_max_recipients": 200, "expected_revision": response.json()["revision"]}
actor[0] = principal("system:settings:read")
assert client.put(path, json=payload).status_code == 403
actor[0] = principal("admin:policies:read", "admin:policies:write")
assert client.get(path).status_code == 403
assert client.put(path, json=payload).status_code == 403
actor[0] = policy.admin
assert client.put(path, json={**payload, "settings": {"unrelated": "overwrite"}}).status_code == 422
assert client.put(path, json={**payload, "synchronous_send_max_recipients": True}).status_code == 422
saved = client.put(path, json=payload)
assert saved.status_code == 200 and saved.json()["effective_max_recipients"] == 200
assert client.put(path, json=payload).status_code == 409
assert client.get("/api/v1/campaigns/settings/delivery-policy/user").status_code == 422
actor[0] = principal()
assert client.get(path).status_code == 403
assert policy.audit.call_args.kwargs["object_type"] == "campaign_delivery_policy"
+48
View File
@@ -6,7 +6,9 @@ import pytest
from pydantic import ValidationError
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
campaign_editor_state_for_edit,
campaign_editor_state_with_client_update,
)
from govoplan_campaign.backend.schemas import (
CampaignVersionResponse,
@@ -19,6 +21,7 @@ from govoplan_campaign.backend.schemas import (
[
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
{"transport": {"imap_password": "secret"}},
{"approval_gate": {"request_id": "forged"}},
{
"review_send": {
"build_token": "forged",
@@ -108,3 +111,48 @@ def test_fork_copy_keeps_only_client_owned_bounded_metadata() -> None:
"field_overrides": {"department": False},
}
assert "legacy-secret" not in repr(copied)
def test_client_metadata_update_preserves_only_trusted_server_evidence() -> None:
review = {
"build_token": "server-build-token",
"inspection_complete": True,
"reviewed_message_keys": ["entry-1"],
"issue_decisions": [],
"updated_at": "2026-07-21T00:00:00+00:00",
"updated_by_user_id": "reviewer-1",
}
approval = {
"request_id": "approval-1",
"request_revision": 2,
"subject_version": "version-1",
"subject_digest": "a" * 64,
"requested_at": "2026-07-21T00:00:00+00:00",
"requested_by_user_id": "approver-1",
}
stored = {
"created_from": "minimal_campaign",
"opt_ins": {"inline_guidance": True},
"review_send": review,
"approval_gate": approval,
"credentials": {"password": "legacy-secret"},
}
updated = campaign_editor_state_with_client_update(
stored, {"opt_ins": {"inline_guidance": False}}
)
assert updated == {
"opt_ins": {"inline_guidance": False},
"review_send": review,
"approval_gate": approval,
}
assert updated["review_send"] is not review
assert updated["approval_gate"] is not approval
assert stored["opt_ins"] == {"inline_guidance": True}
assert "legacy-secret" not in repr(updated)
@pytest.mark.parametrize("server_key", ["review_send", "approval_gate"])
def test_client_metadata_update_cannot_replace_server_evidence(server_key: str) -> None:
with pytest.raises(CampaignMailProfileBoundaryError, match="unsupported"):
campaign_editor_state_with_client_update({}, {server_key: {}})
+1
View File
@@ -304,6 +304,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
session = MagicMock()
session.get.return_value = job
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
session.query.return_value.filter.return_value.update.return_value = 1
with (
patch(
+52
View File
@@ -0,0 +1,52 @@
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
from govoplan_campaign.backend.integrations import ImapAppendError, MailCampaignIntegration
def test_older_mail_capability_keeps_single_message_compatibility():
integration = MailCampaignIntegration(SimpleNamespace())
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as state:
assert state is None
def test_optional_batch_forwards_scope_and_cleans_up_when_caller_fails():
calls = []
state = SimpleNamespace(connection_count=0, reconnect_count=0)
@contextmanager
def batch(**kwargs):
calls.append(kwargs)
try:
yield state
finally:
calls.append("closed")
integration = MailCampaignIntegration(SimpleNamespace(campaign_imap_batch=batch))
with pytest.raises(ValueError, match="caller"):
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as actual:
assert actual is state
raise ValueError("caller failure")
assert calls == [{"tenant_id": "tenant", "campaign_id": "campaign"}, "closed"]
def test_optional_batch_translates_unknown_outcome_without_losing_flags():
class ProviderAppendError(RuntimeError):
temporary = False
outcome_unknown = True
@contextmanager
def batch(**kwargs):
raise ProviderAppendError("inspect mailbox")
yield None
integration = MailCampaignIntegration(SimpleNamespace(
campaign_imap_batch=batch, ImapAppendError=ProviderAppendError,
))
with pytest.raises(ImapAppendError) as caught:
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign"):
pass
assert caught.value.outcome_unknown
assert caught.value.temporary is False
@@ -0,0 +1,245 @@
"""Real database/route checks: a review acceptance is durable before completion."""
from __future__ import annotations
import copy
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import Column, String, Table, create_engine, event
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
from govoplan_campaign.backend.routes import versions as routes
from govoplan_campaign.backend.schemas import CampaignReviewStateRequest, CampaignVersionDetailResponse
from govoplan_campaign.backend.services.job_queries import _review_metadata, _review_metadata_counts
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
from govoplan_campaign.backend.sending.jobs import _reviewed_needs_review_keys
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
@pytest.fixture
def review(tmp_path):
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'review.db'}")
for name in ("access_users", "access_groups"):
if name not in Base.metadata.tables:
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
Base.metadata.create_all(engine, tables=[
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__, CampaignJob.__table__,
])
with Session(engine) as session:
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="C1", name="Campaign", current_version_id="version-1")
version = CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1,
raw_json={"version": "1.0", "campaign": {"id": "C1", "name": "Campaign"}},
build_summary={"build_token": "private-build-1", "built_count": 2}, editor_state={"created_from": "minimal_campaign"})
session.add_all([campaign, version, _job(1), _job(2)])
session.commit()
audits = []
def audit(current_session, _principal, **kwargs):
audits.append(kwargs)
if kwargs.get("commit"):
current_session.commit()
with patch.object(routes, "_get_campaign_for_principal", return_value=campaign), patch.object(routes, "audit_from_principal", side_effect=audit):
yield SimpleNamespace(engine=engine, session=session, version=version, campaign=campaign, audits=audits)
engine.dispose()
def _job(index, *, validation="needs_review", build="built", code="missing_optional_attachment", behavior="ask"):
return CampaignJob(id=f"job-{index}", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1",
entry_index=index, entry_id=f"entry-{index}", validation_status=validation, build_status=build,
eml_sha256=str(index % 10) * 64, issues_snapshot=[{"code": code, "behavior": behavior, "source": "attachments", "details": {"rule_id": f"rule-{index}"}}])
def _principal(actor="reviewer-1"):
return ApiPrincipal(principal=PrincipalRef(account_id=actor, membership_id=actor, tenant_id="tenant-1", scopes=frozenset({"campaigns:campaign:review"})), account=SimpleNamespace(id=actor), user=SimpleNamespace(id=actor))
def _save(review, *, ids=(1,), complete=False, actor="reviewer-1", session=None, **overrides):
payload = {
"inspection_complete": complete, "merge_progress": True,
"build_token": review.version.review_build_token, "base_revision": review.version.edit_revision,
"reviewed_message_keys": [f"entry-{index}" for index in ids],
"issue_decisions": [{"job_id": f"job-{index}", "decision": "accept", "reason": f"Reason {index}"} for index in ids],
**overrides,
}
return routes.set_version_review_state("campaign-1", "version-1", CampaignReviewStateRequest(**payload), session=session or review.session, principal=_principal(actor))
def test_partial_reason_and_reviewed_state_survive_fresh_reload_without_completion(review):
response = _save(review)
review.session.expire_all()
stored = review.session.get(CampaignVersion, "version-1")
state = stored.editor_state["review_send"]
assert state["inspection_complete"] is False
assert state["reviewed_message_keys"] == ["entry-1"]
assert state["issue_decisions"][0]["reason"] == "Reason 1"
assert state["issue_decisions"][0]["actor_user_id"] == "reviewer-1"
assert state["issue_decisions"][0]["message_sha256"] == "1" * 64
assert response.edit_revision == 2
assert response.review_build_token and "private-build-1" not in repr(response)
public_state = response.editor_state["review_send"]
assert public_state["review_build_token"] == response.review_build_token
assert "build_token" not in public_state and "build_token" not in (response.build_summary or {})
metadata, reviewed_keys = _review_metadata(review.session, stored, [CampaignJob.campaign_version_id == stored.id])
assert metadata["reviewed_required_count"] == 1 and reviewed_keys == {"entry-1"}
assert _reviewed_needs_review_keys(stored) == set(), "partial progress never authorizes delivery"
assert review.audits[-1]["action"] == "campaign.message_review_updated"
def test_second_reviewer_and_final_completion_preserve_first_decision_evidence(review):
_save(review)
first = copy.deepcopy(review.version.editor_state["review_send"]["issue_decisions"][0])
_save(review, ids=(2,), actor="reviewer-2")
state = review.version.editor_state["review_send"]
assert state["reviewed_message_keys"] == ["entry-1", "entry-2"]
assert state["issue_decisions"][0] == first
second = copy.deepcopy(state["issue_decisions"][1])
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 1
assert review.audits[-1]["details"]["issue_decisions"] == routes._review_decision_audit_evidence(review.version, job_ids={"job-2"})
_save(review, ids=(), complete=True, actor="reviewer-3")
review.session.expire_all()
state = review.version.editor_state["review_send"]
assert state["inspection_complete"] is True
assert state["issue_decisions"] == [first, second]
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 2
assert _reviewed_needs_review_keys(review.version) == {"entry-1", "entry-2"}
def test_incremental_save_loads_only_selected_job_and_never_materializes_files(review):
review.session.add_all([_job(index, validation="ready", behavior="continue") for index in range(3, 503)])
review.session.commit()
review.session.refresh(review.version)
review.session.refresh(review.campaign)
review.session.expunge_all()
loaded_jobs = []
def loaded(_session, instance):
if isinstance(instance, CampaignJob):
loaded_jobs.append(instance.id)
event.listen(review.session, "loaded_as_persistent", loaded)
try:
with patch("govoplan_campaign.backend.persistence.campaigns.load_version_config", side_effect=AssertionError("Review progress must not rebuild or resolve Files")):
_save(review)
assert loaded_jobs == ["job-1"]
finally:
event.remove(review.session, "loaded_as_persistent", loaded)
@pytest.mark.parametrize("mutation", ["unknown_job", "unknown_key", "missing_reason", "duplicate", "blocked", "hard_issue", "excluded", "category"])
def test_invalid_incremental_acceptance_is_atomic(review, mutation):
options = {}
if mutation == "unknown_job":
options["issue_decisions"] = [{"job_id": "outside-build", "reason": "Not allowed"}]
elif mutation == "unknown_key":
options["reviewed_message_keys"] = ["outside-build"]
elif mutation == "missing_reason":
options["issue_decisions"] = [{"job_id": "job-1", "reason": " "}]
elif mutation == "duplicate":
options["issue_decisions"] = [{"job_id": "job-1", "reason": "A"}, {"job_id": "job-1", "reason": "B"}]
elif mutation == "category":
options["decision_category_key"] = "wrong-category"
else:
job = review.session.get(CampaignJob, "job-1")
if mutation == "hard_issue":
job.issues_snapshot = [{"code": "required", "source": "attachments", "behavior": "block"}]
else:
job.validation_status = mutation
review.session.commit()
original = copy.deepcopy(review.version.editor_state)
with pytest.raises(HTTPException) as error:
_save(review, **options)
assert error.value.status_code == 422
review.session.expire_all()
assert review.version.editor_state == original and review.version.edit_revision == 1
assert review.audits == []
@pytest.mark.parametrize("precondition", ["build", "revision"])
def test_stale_progress_is_conflict_and_preserves_acknowledged_progress(review, precondition):
_save(review)
options = {"build_token": "old-build"} if precondition == "build" else {"base_revision": 1}
with pytest.raises(HTTPException) as error:
_save(review, ids=(2,), **options)
assert error.value.status_code == 409
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
def test_same_category_bulk_is_bound_to_exact_selected_jobs(review):
category = review_decision_metadata(review.session.get(CampaignJob, "job-1"))["category_key"]
result = _save(review, ids=(1, 2), decision_category_key=category)
assert result.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
assert len(result.editor_state["review_send"]["issue_decisions"]) == 2
def test_deliberately_excluded_and_inactive_rows_do_not_block_completion_or_need_bulk_acceptance(review):
review.session.add_all([_job(3, validation="excluded", build="skipped", behavior="drop"), _job(4, validation="inactive", build="skipped", behavior="continue")])
review.session.commit()
_save(review, ids=(1, 2), complete=True)
counts = _review_metadata_counts([(None, 3, "skipped", "excluded"), (None, 4, "skipped", "inactive")], set())
assert counts == {"blocking_count": 0, "required_count": 0, "reviewed_required_count": 0, "bulk_acceptable_count": 0}
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
assert not review_decision_metadata(review.session.get(CampaignJob, "job-3"))["eligible"]
def test_final_review_cannot_override_remaining_hard_blockers(review):
review.session.add(_job(3, validation="blocked", build="build_failed", behavior="block"))
review.session.commit()
_save(review, ids=(1, 2))
with pytest.raises(HTTPException, match="Blocked or failed"):
_save(review, ids=(), complete=True)
assert review.version.editor_state["review_send"]["inspection_complete"] is False
def test_existing_frozen_blocker_is_not_reclassified_from_current_allowed_empty_settings(review):
job = review.session.get(CampaignJob, "job-1")
job.validation_status = "blocked"
job.build_status = "build_failed"
job.issues_snapshot = [{"code": "missing_required_attachment", "source": "attachments", "behavior": "block"}]
review.version.raw_json = {**review.version.raw_json, "attachments": {"missing_behavior": "continue", "send_without_attachments_behavior": "continue"}}
review.session.commit()
evidence = copy.deepcopy(job.issues_snapshot)
with pytest.raises(HTTPException):
_save(review, ids=(1,))
assert job.issues_snapshot == evidence and job.validation_status == "blocked"
def test_concurrent_database_write_is_409_without_losing_other_reviewer(review):
# Keep a genuinely stale ORM identity in a second transaction so the SQL
# version-column check, rather than just the request comparison, must fire.
with Session(review.engine) as stale_session:
stale = stale_session.get(CampaignVersion, "version-1")
assert stale is not None and stale.edit_revision == 1
_save(review, ids=(1,))
with pytest.raises(HTTPException) as error:
_save(review, ids=(2,), session=stale_session, base_revision=1)
assert error.value.status_code == 409
review.session.expire_all()
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
assert len(review.audits) == 1
def test_delivery_final_lock_rejects_incremental_acceptance(review):
review.version.workflow_state = "completed"
review.session.commit()
with pytest.raises(HTTPException) as error:
_save(review)
assert error.value.status_code == 409
assert "review_send" not in review.version.editor_state
def test_owner_denial_rejects_before_persisting_review(review):
with patch.object(routes, "_get_campaign_for_principal", side_effect=HTTPException(status_code=403, detail="Owner access denied")):
with pytest.raises(HTTPException) as error:
_save(review)
assert error.value.status_code == 403
assert "review_send" not in review.version.editor_state and review.audits == []
def test_progress_contract_requires_both_preconditions():
with pytest.raises(ValidationError, match="build_token and base_revision"):
CampaignReviewStateRequest(merge_progress=True, build_token="build-1")
@@ -0,0 +1,317 @@
"""Exercise real version routes/persistence without transport or filesystem effects."""
from __future__ import annotations
import copy
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
assert_archive_encryption_allowed,
stamp_legacy_zipcrypto_acknowledgements,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
)
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
from govoplan_campaign.backend.integrations import MailProfileError
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
build_campaign_version,
load_campaign_config_from_json,
validate_campaign_version,
)
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionUpdateRequest
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1", membership_id="user-1", tenant_id="tenant-1",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
def _legacy() -> dict:
return {
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"server": {
"mail_profile_id": "profile-1",
"smtp": {"host": "old.example.test", "password": "stored-secret"},
"imap": None,
"credentials": {},
"inherit_smtp_credentials": True,
},
"recipients": {"from": [{"email": "sender@example.test"}]},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"entries": {"inline": []},
"attachments": {"zip": {"enabled": True, "archives": [{
"id": "archive-1", "method": "zip_standard", "password_enabled": True,
"password_delivery_channel": "separate_mail",
}]}},
}
@pytest.fixture
def repair():
engine = create_engine("sqlite+pysqlite:///:memory:")
for name in ("access_users", "access_groups"):
if name not in Base.metadata.tables:
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
Base.metadata.create_all(engine, tables=[
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__,
CampaignIssue.__table__,
])
with Session(engine) as session:
campaign = Campaign(
id="campaign-1", tenant_id="tenant-1", external_id="campaign-1",
name="Campaign", current_version_id="draft-1",
)
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=_legacy())
history = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=_legacy(), workflow_state="final")
session.add_all((campaign, draft, history))
session.commit()
audits: list[dict] = []
def audit(_session, _principal, **kwargs):
audits.append(kwargs)
if kwargs.get("commit"):
_session.commit()
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
with (
patch.object(route_support, "_get_campaign_for_principal", return_value=campaign),
patch.object(route_support, "audit_from_principal", side_effect=audit),
patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration),
patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None),
):
yield SimpleNamespace(session=session, campaign=campaign, draft=draft, history=history, integration=integration, audits=audits)
engine.dispose()
def _public(repair) -> dict:
return CampaignVersionDetailResponse.model_validate(repair.draft).raw_json
def _save(repair, raw: dict, *, migrate: bool = False, principal=None):
return route_support._update_campaign_version_detail_response(
repair.session, principal or _principal("mail:profile:use"),
repair.campaign.id, repair.draft.id,
CampaignVersionUpdateRequest(
campaign_json=raw, base_revision=repair.draft.edit_revision,
migrate_legacy_mail_settings=migrate,
),
if_match=repair.draft.strong_etag, autosave=True,
audit_action="campaign.version_autosaved",
)
@pytest.mark.parametrize("mail_first", [True, False])
def test_independent_repairs_persist_in_either_order_without_altering_history(repair, mail_first):
original_zip = copy.deepcopy(repair.draft.raw_json["attachments"]["zip"])
original_server = copy.deepcopy(repair.draft.raw_json["server"])
first = _public(repair)
if not mail_first:
first["attachments"]["zip"]["archives"][0]["method"] = "aes"
result = _save(repair, first, migrate=mail_first)
repair.session.expire_all()
assert repair.draft.edit_revision == 2
assert result.mail_profile_migration_required is not mail_first
assert "stored-secret" not in repr(result)
assert repair.draft.build_summary is None
assert repair.draft.execution_snapshot is None
if mail_first:
assert repair.draft.raw_json["attachments"]["zip"] == original_zip
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
# Real validation/build gates still reject the unresolved ZIP policy.
for action in (validate_campaign_version, build_campaign_version):
with patch(
"govoplan_campaign.backend.persistence.campaigns.load_version_config",
return_value=(repair.draft, None, object()),
), pytest.raises(CampaignPersistenceError, match="blocked"):
action(repair.session, tenant_id="tenant-1", version_id=repair.draft.id)
else:
assert repair.draft.raw_json["server"] == original_server
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
# The authoritative config loader still blocks legacy transport.
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local"):
load_campaign_config_from_json(repair.session, tenant_id="tenant-1", raw_json=repair.draft.raw_json)
second = _public(repair)
if mail_first:
second["attachments"]["zip"]["archives"][0]["method"] = "aes"
result = _save(repair, second, migrate=not mail_first)
repair.session.expire_all()
assert repair.draft.edit_revision == 3
assert not result.mail_profile_migration_required
assert_campaign_uses_mail_profile_reference(repair.draft.raw_json)
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
assert repair.history.raw_json == _legacy()
assert len(repair.audits) == 2
assert repair.audits[0]["details"]["legacy_mail_settings_preserved"] is not mail_first
assert repair.audits[0]["details"]["legacy_mail_settings_migrated"] is mail_first
assert not any(item["details"]["legacy_zipcrypto_acknowledgements"] for item in repair.audits)
def test_zip_repair_does_not_require_use_of_unchanged_revoked_mail_profile(repair):
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError("Profile revoked")
raw = _public(repair)
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
result = _save(repair, raw, principal=_principal())
assert result.mail_profile_migration_required
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
with pytest.raises(HTTPException) as denied:
_save(repair, _public(repair), migrate=True, principal=_principal())
assert denied.value.status_code == 403
assert "mail:profile:use" in denied.value.detail
with pytest.raises(HTTPException, match="Profile revoked"):
_save(repair, _public(repair), migrate=True)
assert repair.draft.mail_profile_migration_required
def test_archive_repair_is_saveable_after_mail_migration_and_a_stricter_credential_policy(repair):
_save(repair, _public(repair), migrate=True)
repair.integration.assert_campaign_mail_policy_allows_json.reset_mock()
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError(
"SMTP credential policy requires an explicit credential selection"
)
raw = _public(repair)
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
result = _save(repair, raw, principal=_principal())
assert not result.mail_profile_migration_required
assert result.raw_json["server"] == {"mail_profile_id": "profile-1"}
assert result.raw_json["attachments"]["zip"]["archives"][0]["method"] == "aes"
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
changed_mail = _public(repair)
changed_mail["server"]["smtp_server_id"] = "smtp-1"
with pytest.raises(HTTPException) as missing_permission:
_save(repair, changed_mail, principal=_principal())
assert missing_permission.value.status_code == 403
with pytest.raises(HTTPException, match="explicit credential selection"):
_save(repair, changed_mail)
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
@pytest.mark.parametrize("mutation", ["new_profile", "remove_profile", "credential", "inline", "echo_inline"])
def test_archive_save_cannot_modify_or_introduce_mail_transport_without_migration(repair, mutation):
raw = _public(repair)
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
if mutation == "new_profile":
raw["server"]["mail_profile_id"] = "other-profile"
elif mutation == "remove_profile":
raw["server"] = {}
elif mutation == "credential":
raw["server"].update(smtp_server_id="smtp-1", smtp_credential_id="credential-1")
elif mutation == "inline":
raw["server"]["smtp"] = {"password": "injected-secret"}
else:
raw["server"] = copy.deepcopy(repair.draft.raw_json["server"])
with pytest.raises(HTTPException):
_save(repair, raw)
assert repair.draft.raw_json == _legacy()
assert repair.draft.edit_revision == 1
@pytest.mark.parametrize("mutation", ["acknowledgement", "reason", "actor", "method", "channel", "typed_boolean"])
def test_mail_migration_cannot_grandfather_modified_zip_settings(repair, mutation):
raw = _public(repair)
archive = raw["attachments"]["zip"]["archives"][0]
if mutation == "typed_boolean":
archive["password_enabled"] = 1 # Python True == 1 is not exact JSON equality.
elif mutation == "method":
archive["method"] = "unknown"
elif mutation == "channel":
archive["method"] = "aes"
archive["password_delivery_channel"] = "same_mail"
else:
archive[{
"acknowledgement": "legacy_zipcrypto_acknowledged",
"reason": "legacy_zipcrypto_reason",
"actor": "legacy_zipcrypto_acknowledged_by",
}[mutation]] = True if mutation == "acknowledgement" else "forged evidence"
with pytest.raises(HTTPException):
_save(repair, raw, migrate=True)
assert repair.draft.raw_json == _legacy()
assert repair.draft.edit_revision == 1
def test_unchanged_zip_is_not_restamped_even_after_legacy_permission_revocation(repair):
raw = _public(repair)
archive = raw["attachments"]["zip"]["archives"][0]
archive.update(
legacy_zipcrypto_acknowledged=True,
legacy_zipcrypto_reason="Existing recipient compatibility requirement",
legacy_zipcrypto_acknowledged_by="original-actor",
legacy_zipcrypto_acknowledged_at="2026-08-01T10:00:00+00:00",
)
repair.draft.raw_json = {**copy.deepcopy(repair.draft.raw_json), "attachments": copy.deepcopy(raw["attachments"])}
repair.session.commit()
result = _save(repair, raw, migrate=True, principal=_principal("mail:profile:use"))
assert result.raw_json["attachments"]["zip"]["archives"][0] == archive
assert repair.audits[-1]["details"]["legacy_zipcrypto_acknowledgements"] == []
with pytest.raises(CampaignArchiveEncryptionError, match="blocked"):
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
with patch(
"govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy",
return_value=SimpleNamespace(
available=True, allowed_password_encryption_methods={"aes", "zip_standard"},
allowed_password_delivery_channels={"separate_mail"}, reason="Policy allows legacy",
),
), pytest.raises(CampaignArchiveEncryptionError, match="Missing scope"):
assert_archive_encryption_allowed(
repair.session, repair.campaign, repair.draft.raw_json,
principal=_principal("mail:profile:use"),
)
def test_unchanged_missing_zip_remains_noop_without_policy_lookup(repair):
with patch("govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy") as policy:
candidate, evidence = stamp_legacy_zipcrypto_acknowledgements(
repair.session, repair.campaign, {}, {"template": {"text": "New"}}, principal=_principal(),
)
assert candidate == {"template": {"text": "New"}}
assert evidence == []
policy.assert_not_called()
@pytest.mark.parametrize("target", ["individual", "global"])
def test_recipient_address_order_round_trips_through_the_real_save_route(repair, target):
addresses = [
{"name": "Zulu", "email": "zulu@example.test"},
{"name": "Alpha", "email": "alpha@example.test"},
{"name": "Beta", "email": "beta@example.test"},
]
raw = _public(repair)
if target == "individual":
raw["entries"]["inline"] = [{"id": "entry-1", "to": addresses}]
else:
raw["recipients"]["to"] = addresses
result = _save(repair, raw, principal=_principal("campaigns:recipient:write"))
repair.session.expire_all()
persisted = repair.session.get(CampaignVersion, "draft-1")
assert persisted is not None
if target == "individual":
assert persisted.raw_json["entries"]["inline"][0]["to"] == addresses
assert result.raw_json["entries"]["inline"][0]["to"] == addresses
else:
assert persisted.raw_json["recipients"]["to"] == addresses
assert result.raw_json["recipients"]["to"] == addresses
assert persisted.mail_profile_migration_required
assert persisted.edit_revision == 2
+54
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from sqlalchemy import create_engine
@@ -12,6 +14,8 @@ from govoplan_campaign.backend.services.job_queries import (
_campaign_jobs_grid_filter_expressions,
_campaign_jobs_ordering,
_campaign_jobs_page_response,
_campaign_jobs_query_context,
_public_recipient_groups,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
@@ -119,6 +123,56 @@ class CampaignJobListQueryTests(unittest.TestCase):
self.assertEqual(raised.exception.status_code, 422)
def test_recipient_projection_preserves_each_group_order_without_extra_fields(self) -> None:
recipients = {"to": [{"email": "second@example.test", "name": "Second", "secret": "never-project"},
{"email": "first@example.test"}],
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}],
"from": {"email": "sender@example.test"}, "private": "never-project"}
result = _public_recipient_groups(recipients)
self.assertEqual(list(result), ["to", "cc", "bcc"])
self.assertEqual([value["email"] for value in result["to"]], ["second@example.test", "first@example.test"])
self.assertNotIn("never-project", str(result))
def test_each_frozen_recipient_group_is_searchable_before_pagination(self) -> None:
row = self.session.get(CampaignJob, "job-0")
row.resolved_recipients = {"to": [{"email": "second-to@example.test"}],
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}]}
self.session.commit()
for recipient in ("second-to", "copy@", "blind@"):
with self.subTest(recipient=recipient):
filters = _campaign_jobs_grid_filter_expressions({"recipient": recipient})
page = _campaign_jobs_page_response(self.session, campaign_id="campaign-1", version_id="version-1",
base_filters=[CampaignJob.tenant_id == "tenant-1"], filtered=filters,
reviewed_keys=set(), review_metadata={}, page=1, page_size=1, grid_filters={"recipient": recipient})
self.assertEqual(page.total, 1)
self.assertEqual(page.jobs[0]["id"], "job-0")
self.assertEqual(page.jobs[0]["resolved_recipients"]["bcc"], [{"email": "blind@example.test"}])
def test_free_search_also_matches_additional_recipients_and_keeps_tenant_scope(self) -> None:
row = self.session.get(CampaignJob, "job-0")
row.resolved_recipients = {"bcc": [{"email": "additional@example.test"}]}
other = self.session.get(CampaignJob, "job-1")
other.tenant_id = "other-tenant"
other.resolved_recipients = row.resolved_recipients
self.session.commit()
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda scope: scope == "campaigns:recipient:read")
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant", return_value=SimpleNamespace(id="campaign-1")), \
patch("govoplan_campaign.backend.services.job_queries._review_metadata", return_value=({}, set())):
_, _, filters, _, _ = _campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
send_status=None, validation_status=None, imap_status=None, query_text="additional@example.test")
self.assertEqual([job.id for job in self.session.query(CampaignJob).filter(*filters)], ["job-0"])
def test_recipient_read_is_required_before_additional_addresses_are_queried(self) -> None:
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda _scope: False)
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant") as lookup:
with self.assertRaises(HTTPException) as raised:
_campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
send_status=None, validation_status=None, imap_status=None, query_text="blind@example.test")
self.assertEqual(raised.exception.status_code, 403)
lookup.assert_not_called()
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
+135 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import call, patch
from unittest.mock import Mock, call, patch
import pytest
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session
from govoplan_campaign.backend import route_support
from govoplan_campaign.backend.routes import attachments as attachment_routes
@@ -17,11 +19,14 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_profile_id,
)
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
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.persistence.versions import _updated_runtime_json, update_campaign_version
from govoplan_campaign.backend.integrations import MailCampaignIntegration, MailProfileError
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
@@ -237,6 +242,133 @@ def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> Non
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
def test_explicit_legacy_migration_accepts_an_unchanged_authorized_profile_reference() -> None:
legacy = _campaign_json({
"mail_profile_id": "profile-1",
"smtp": {"host": "smtp.example.test", "password": "legacy-secret"},
"imap": None,
"credentials": {},
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
})
submitted = _campaign_json({"mail_profile_id": "profile-1"})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
session = object()
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
result = _updated_runtime_json(
session, # type: ignore[arg-type]
tenant_id="tenant-1",
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(raw_json=legacy),
raw_json=submitted,
source_base_path=None,
migrate_legacy_mail_settings=True,
)
assert result == submitted
assert result is not submitted
assert campaign_mail_profile_boundary_violations(result) == ()
assert "legacy-secret" not in repr(result)
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
integration.assert_campaign_mail_policy_allows_json.assert_called_once_with(
session, tenant_id="tenant-1", raw_json=result, campaign_id="campaign-1"
)
def test_explicit_legacy_migration_requires_a_profile_and_preserves_mail_authorization() -> None:
legacy = _campaign_json({"smtp": {"password": "legacy-secret"}})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock(
side_effect=MailProfileError("Mail profile is not authorized for this campaign")
))
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
for submitted, expected_error in (
(_campaign_json(), CampaignPersistenceError),
(_campaign_json({"mail_profile_id": "unauthorized-profile"}), MailProfileError),
(_campaign_json({"mail_profile_id": "profile-1", "smtp": {}}), CampaignMailProfileBoundaryError),
):
with pytest.raises(expected_error):
_updated_runtime_json(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign=SimpleNamespace(id="campaign-1"),
version=SimpleNamespace(raw_json=legacy),
raw_json=submitted,
source_base_path=None,
migrate_legacy_mail_settings=True,
)
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
def test_persisted_legacy_draft_can_migrate_then_save_other_content_without_changing_history() -> None:
engine = create_engine("sqlite+pysqlite:///:memory:")
for table_name in ("access_users", "access_groups"):
if table_name not in Base.metadata.tables:
Table(table_name, Base.metadata, Column("id", String(36), primary_key=True))
Base.metadata.create_all(engine, tables=[
Base.metadata.tables["access_users"],
Base.metadata.tables["access_groups"],
ChangeSequenceEntry.__table__,
Campaign.__table__,
CampaignVersion.__table__,
CampaignIssue.__table__,
])
legacy = _campaign_json({
"mail_profile_id": "profile-1",
"smtp": {"host": "old.example.test", "password": "historical-secret"},
"imap": None,
"credentials": {},
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
})
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
try:
with Session(engine) as session, patch(
"govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration
):
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="campaign-1", name="Campaign", current_version_id="draft-1")
historical = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=legacy, workflow_state="final")
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=legacy, editor_state={"created_from": "minimal_campaign"})
session.add_all((campaign, historical, draft))
session.commit()
original_revision = draft.edit_revision
assert draft.mail_profile_migration_required
migrated = update_campaign_version(
session,
tenant_id="tenant-1", campaign_id=campaign.id, version_id=draft.id,
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
editor_state={"created_from": "minimal_campaign"},
expected_revision=original_revision,
migrate_legacy_mail_settings=True,
autosave=True,
)
session.expire_all()
persisted = session.get(CampaignVersion, migrated.id)
assert persisted is not None
assert not persisted.mail_profile_migration_required
assert persisted.raw_json["server"] == {"mail_profile_id": "profile-1"}
assert persisted.edit_revision > original_revision
assert persisted.autosaved_at is not None
assert "historical-secret" not in repr(persisted.raw_json)
assert session.get(CampaignVersion, historical.id).raw_json == legacy
edited = _campaign_json({"mail_profile_id": "profile-1"})
edited["template"]["subject"] = "Edited after migration" # type: ignore[index]
updated = update_campaign_version(
session,
tenant_id="tenant-1", campaign_id=campaign.id, version_id=persisted.id,
raw_json=edited,
expected_revision=persisted.edit_revision,
)
session.expire_all()
assert session.get(CampaignVersion, updated.id).raw_json["template"]["subject"] == "Edited after migration"
assert session.get(CampaignVersion, historical.id).raw_json == legacy
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
finally:
engine.dispose()
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
+142
View File
@@ -0,0 +1,142 @@
"""Mock accepted exceptions using real frozen jobs/EML, never new live effects."""
from __future__ import annotations
import copy
import hashlib
from email import policy
from email.message import EmailMessage
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from test_incremental_review_persistence import review, _save
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.db.models import CampaignJob
from govoplan_campaign.backend.dev import mock_campaign
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot
@pytest.fixture
def frozen(review, tmp_path):
review.version.raw_json = {**review.version.raw_json, "server": {"mail_profile_id": "profile-1"}}
jobs = review.session.query(CampaignJob).order_by(CampaignJob.entry_index).all()
for job in jobs:
job.subject = f"Frozen subject {job.entry_index}"
job.resolved_recipients = {"from": {"email": "sender@example.test"}, "to": [{"email": f"recipient-{job.entry_index}@example.test"}]}
job.issues_snapshot = [{**item, "severity": "warning", "message": "Explicit attachment exception"} for item in job.issues_snapshot]
message = EmailMessage()
message["From"] = "sender@example.test"
message["To"] = f"recipient-{job.entry_index}@example.test"
message["Subject"] = job.subject
message["Date"] = "Mon, 07 Sep 2026 09:00:00 +0200"
message["Message-ID"] = f"<frozen-{job.entry_index}@example.test>"
message.set_content(f"Frozen reviewed body {job.entry_index}")
payload = message.as_bytes(policy=policy.SMTP)
path = tmp_path / f"message-{job.entry_index}.eml"
path.write_bytes(payload)
job.eml_local_path = str(path)
job.eml_size_bytes = len(payload)
job.eml_sha256 = hashlib.sha256(payload).hexdigest()
job.message_id_header = str(message["Message-ID"])
review.session.flush()
payload, digest = create_execution_snapshot(review.version, mail_profile_id="profile-1",
smtp_transport_revision="smtp-1", imap_transport_revision="imap-1", delivery=DeliveryConfig(),
jobs=jobs, build_summary=review.version.build_summary)
review.version.execution_snapshot = payload
review.version.execution_snapshot_hash = digest
review.session.commit()
_save(review, ids=(1, 2), complete=True)
mailbox = Mock()
mailbox.consume_fail_next_smtp.return_value = False
mailbox.consume_fail_next_imap.return_value = False
mailbox.get_failures.return_value = {}
mailbox.record_smtp_delivery.return_value = SimpleNamespace(id="mock-1")
mailbox.record_imap_append.return_value = SimpleNamespace(id="mock-imap-1")
mailbox.list_records.return_value = []
with (
patch.object(mock_campaign, "_mock_mailbox", return_value=mailbox),
patch("govoplan_campaign.backend.sending.execution.files_integration", return_value=SimpleNamespace(available=False)),
patch("govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed", return_value=SimpleNamespace(policy_hash="archive-policy")),
patch("govoplan_campaign.backend.sending.execution.profile_delivery_summary", return_value={"smtp_transport_revision": "smtp-1", "imap_transport_revision": "imap-1"}) as transport,
):
yield SimpleNamespace(**vars(review), jobs=jobs, mailbox=mailbox, transport=transport)
def _run(frozen, **options):
return mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", version_id="version-1",
use_reviewed_build=True, include_needs_review=False, send=True, **options)
def test_mock_reuses_reviewed_frozen_bytes_and_decisions_without_reasking_or_mutating_campaign(frozen):
original = copy.deepcopy(frozen.version.editor_state)
revision = frozen.version.edit_revision
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=AssertionError("Do not rebuild reviewed messages")):
result = _run(frozen)
assert result["send"]["sent_count"] == 2 and result["send"]["skipped_count"] == 0
assert result["use_reviewed_build"] is True and result["build"]["review_satisfied"] is True
assert [step["status"] for step in result["steps"]] == ["ok", "ok", "ok", "ok"]
assert frozen.mailbox.record_smtp_delivery.call_count == 2
captured = frozen.mailbox.record_smtp_delivery.call_args_list[0].args[0]
assert str(captured["Message-ID"]) == "<frozen-1@example.test>"
assert "Frozen reviewed body 1" in captured.get_content()
frozen.session.expire_all()
assert frozen.version.editor_state == original and frozen.version.edit_revision == revision
assert all(job.send_status == "not_queued" for job in frozen.jobs)
@pytest.mark.parametrize("mutation", ["partial", "build", "issue", "reason_evidence", "configuration", "snapshot", "bytes", "transport", "imap"])
def test_invalid_frozen_review_stops_before_mock_capture_or_mailbox_clear(frozen, mutation):
if mutation == "partial":
state = copy.deepcopy(frozen.version.editor_state)
state["review_send"]["inspection_complete"] = False
frozen.version.editor_state = state
elif mutation == "build":
frozen.version.build_summary = {"build_token": "different-build", "built_count": 2}
elif mutation == "issue":
frozen.jobs[1].issues_snapshot = [{"code": "new-hard-block", "source": "attachments", "behavior": "block", "severity": "error", "message": "Missing required attachment"}]
elif mutation == "reason_evidence":
state = copy.deepcopy(frozen.version.editor_state)
state["review_send"]["issue_decisions"][0]["issue_fingerprint"] = "f" * 64
frozen.version.editor_state = state
elif mutation == "configuration":
frozen.version.raw_json = {**frozen.version.raw_json, "template": {"subject": "Unreviewed changed subject"}}
elif mutation == "snapshot":
frozen.version.execution_snapshot = None
elif mutation == "bytes":
from pathlib import Path
Path(frozen.jobs[1].eml_local_path).write_bytes(b"changed bytes")
else:
frozen.transport.return_value = {"smtp_transport_revision": "changed" if mutation == "transport" else "smtp-1", "imap_transport_revision": "changed" if mutation == "imap" else "imap-1"}
frozen.session.flush()
with pytest.raises((mock_campaign.MockCampaignSendError, ExecutionSnapshotError, CampaignPersistenceError)):
_run(frozen, clear_mailbox=True)
frozen.mailbox.record_smtp_delivery.assert_not_called()
frozen.mailbox.record_imap_append.assert_not_called()
frozen.mailbox.clear_records.assert_not_called()
def test_policy_dropped_frozen_message_stays_skipped_even_with_ask_evidence(frozen):
excluded = frozen.jobs[1]
excluded.validation_status = "excluded"
excluded.send_status = "skipped"
excluded.imap_status = "skipped"
payload, digest = create_execution_snapshot(frozen.version, mail_profile_id="profile-1", smtp_transport_revision="smtp-1",
imap_transport_revision="imap-1", delivery=DeliveryConfig(), jobs=frozen.jobs, build_summary=frozen.version.build_summary)
frozen.version.execution_snapshot = payload
frozen.version.execution_snapshot_hash = digest
state = copy.deepcopy(frozen.version.editor_state)
state["review_send"]["reviewed_message_keys"] = ["entry-1"]
state["review_send"]["issue_decisions"] = state["review_send"]["issue_decisions"][:1]
frozen.version.editor_state = state
frozen.session.commit()
result = _run(frozen)
assert result["send"]["sent_count"] == 1 and result["send"]["skipped_count"] == 1
assert frozen.mailbox.record_smtp_delivery.call_count == 1
def test_default_authoring_mock_still_uses_transient_preview(frozen):
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=RuntimeError("authoring preview path")):
with pytest.raises(RuntimeError, match="authoring preview path"):
mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", send=False)
+7 -1
View File
@@ -8,6 +8,7 @@ from govoplan_campaign.backend.routes.assignments import router as assignments_r
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.delivery_settings import router as delivery_settings_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
from govoplan_campaign.backend.routes.reports import router as reports_router
@@ -27,6 +28,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = (
delivery_settings_router,
operations_router,
transfers_router,
campaigns_router,
@@ -48,12 +50,16 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 96
assert len(actual) == 100
assert not [operation for operation, count in Counter(actual).items() if count > 1]
def test_key_routes_are_owned_by_their_focused_router() -> None:
expectations = (
(delivery_settings_router, ("GET", "/campaigns/settings/delivery-policy/{scope}")),
(delivery_settings_router, ("PUT", "/campaigns/settings/delivery-policy/{scope}")),
(delivery_router, ("GET", "/campaigns/{campaign_id}/delivery-progress")),
(delivery_router, ("POST", "/campaigns/{campaign_id}/jobs/{job_id}/recover-claim")),
(
operations_router,
("POST", "/campaigns/operations/artifacts/reconcile"),
+4
View File
@@ -49,6 +49,8 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
"system_max_recipient_jobs": 200,
"deployment_ceiling_explicit": False,
"provider_diagnostic": "provider-secret-policy",
},
results=[
@@ -107,6 +109,8 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
"source": "deployment_default",
"deployment_max_recipient_jobs": 25,
"tenant_max_recipient_jobs": None,
"system_max_recipient_jobs": 200,
"deployment_ceiling_explicit": False,
}
audit_details = audit.call_args.kwargs["details"]
+67 -1
View File
@@ -8,6 +8,7 @@ import pytest
from fastapi import HTTPException
from govoplan_campaign.backend import router as campaign_api
from govoplan_core.tenancy.scope import Tenant
from govoplan_campaign.backend.routes import delivery as router
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
@@ -20,8 +21,14 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.integrations import (
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
)
from govoplan_campaign.backend.sending.jobs import (
QueueCampaignResult,
SendJobError,
SynchronousSendRejected,
_ensure_synchronous_send_count_allowed,
_pause_jobs_after_systemic_smtp_failure,
@@ -39,7 +46,7 @@ class _PolicySession:
self.tenant = SimpleNamespace(settings=settings or {})
def get(self, _model, _id):
return self.tenant
return self.tenant if _model is Tenant else None
def _version() -> SimpleNamespace:
@@ -218,6 +225,65 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
batch_preflight.assert_not_called()
@pytest.mark.parametrize(
("error", "reason", "message", "fails_on_exit"),
[
(MailProfileError("private credential detail"), "mail_profile_preflight_failed", "credential selection", False),
(SmtpConfigurationError("private credential detail"), "smtp_configuration_preflight_failed", "SMTP configuration", False),
(SmtpSendError("private credential detail", reason_code="smtp_authentication_failed"), "smtp_authentication_failed", "SMTP authentication failed", False),
(SmtpSendError("private credential detail", reason_code="smtp_preflight_rejected"), "smtp_preflight_rejected", "server rejected", False),
(SmtpSendError("private credential detail", reason_code="private provider code"), "smtp_connectivity_unavailable", "connectivity", False),
(OSError("private credential detail"), "smtp_connectivity_unavailable", "connectivity", False),
(OSError("private credential detail"), None, "Messages may already have been sent", True),
],
)
def test_smtp_preflight_errors_distinguish_policy_configuration_and_connection_without_secrets(
error: Exception, reason: str | None, message: str, fails_on_exit: bool,
) -> None:
session = Mock()
campaign = SimpleNamespace(id="campaign-1")
job = _job("one", queue_status="queued", send_status="queued")
policy = effective_synchronous_send_policy(_PolicySession(), tenant_id="tenant-1", environ={})
@contextmanager
def rejected_connection():
if fails_on_exit:
yield SimpleNamespace(connection_count=1, reconnect_count=0)
raise error
with (
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=_version()),
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
patch("govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy", return_value=policy),
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=[job]),
patch("govoplan_campaign.backend.sending.jobs.queue_campaign_jobs") as queue,
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=[job]),
patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch", return_value={"one": Mock()}),
patch("govoplan_campaign.backend.sending.jobs._synchronous_smtp_batch_manager", return_value=rejected_connection()),
patch("govoplan_campaign.backend.sending.jobs._deliver_job_with_recovery", return_value=SimpleNamespace(status="smtp_accepted", as_dict=lambda: {"status": "smtp_accepted"})) as deliver,
pytest.raises(SendJobError if fails_on_exit else SynchronousSendRejected) as rejected,
):
send_campaign_now(session, tenant_id="tenant-1", campaign_id=campaign.id)
assert message in str(rejected.value)
assert "private" not in str(rejected.value)
assert queue.call_args.kwargs["commit_queue"] is False
session.rollback.assert_called_once_with()
if fails_on_exit:
assert "no message was sent" not in str(rejected.value)
session.commit.assert_called_once_with()
deliver.assert_called_once()
else:
assert rejected.value.reason == reason
assert "no message was sent" in str(rejected.value)
assert "private" not in str(rejected.value.audit_details())
assert rejected.value.eligible_count == 1
session.commit.assert_not_called()
deliver.assert_not_called()
@pytest.mark.parametrize(
("workers_available", "expected_mode", "expected_enqueued"),
((False, "database_queue", 0), (True, "worker_queue", 1)),
+183
View File
@@ -0,0 +1,183 @@
"""Disposable SQLite and in-process HTTP only; never run Docker or a mail provider."""
from __future__ import annotations
import sqlite3
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion, SendAttempt
from govoplan_campaign.backend.routes import delivery as routes
from govoplan_campaign.backend.sending import jobs
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.recovery import RecoveryOperation, verify_recovery_evidence_chain
from govoplan_core.db.session import get_session
from test_mail_testbed_acceptance import runner, _settings, _Response, FIXTURE_PATH
from test_workerless_recovery import recovery, _stale_claim # noqa: F401
@pytest.fixture
def fixture_claim(recovery, tmp_path, monkeypatch):
root = tmp_path / "govoplan-campaign-greenmail-regression"
root.mkdir()
database_file = root / "acceptance.db"
with recovery.engine.connect() as source, sqlite3.connect(database_file) as target:
source.connection.driver_connection.backup(target)
engine = create_engine(f"sqlite:///{database_file}")
factory = sessionmaker(engine)
session = factory()
fixture = SimpleNamespace(
session=session, factory=factory, engine=engine, root=root,
campaign=session.get(Campaign, "campaign"), version=session.get(CampaignVersion, "version"),
snapshot=recovery.snapshot, provider=recovery.provider,
)
monkeypatch.setattr(jobs, "get_database", lambda: SimpleNamespace(SessionLocal=factory))
job, lease, node, operation_id = _stale_claim(fixture, expired=False, owner="active")
node.metadata_ = {"acceptance_worker_pid": 12345}
session.commit()
fixture.job, fixture.lease, fixture.node, fixture.operation_id = job, lease, node, operation_id
fixture.process = SimpleNamespace(pid=12345, returncode=-9, poll=lambda: -9)
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_file}")
monkeypatch.setattr(runner.tempfile, "gettempdir", lambda: str(tmp_path))
yield fixture
session.close()
engine.dispose()
def _recover(fixture, client):
return runner.recover_stopped_fixture_claim(
client, {}, database=SimpleNamespace(engine=fixture.engine, SessionLocal=fixture.factory),
runtime_root=fixture.root, campaign_id="campaign", version_id="version", stopped_process=fixture.process,
)
def test_fixture_proof_calls_actual_fenced_http_action_without_replaying_smtp(fixture_claim, monkeypatch):
fixture = fixture_claim
app = FastAPI()
app.include_router(routes.router, prefix="/api/v1")
actor = SimpleNamespace(id="operator")
scopes = frozenset({"campaigns:campaign:reconcile", "campaigns:recipient:read"})
app.dependency_overrides[get_api_principal] = lambda: ApiPrincipal(
principal=PrincipalRef(account_id="operator", membership_id="operator", tenant_id="tenant", scopes=scopes),
user=actor, account=actor,
)
def session_dependency():
with fixture.factory() as session:
yield session
app.dependency_overrides[get_session] = session_dependency
monkeypatch.setattr(routes, "_get_campaign_for_principal", lambda session, *_args, **_kwargs: session.get(Campaign, "campaign"))
monkeypatch.setattr(routes, "audit_from_principal", lambda session, *_args, **_kwargs: session.commit())
with TestClient(app) as client:
evidence = _recover(fixture, client)
assert evidence == {"stopped_process_verified": True, "fixture_lease_expired": True, "explicit_fenced_recovery": True}
fixture.session.expire_all()
assert fixture.job.send_status == "outcome_unknown"
assert fixture.job.claim_token is None
assert fixture.session.query(SendAttempt).one().status == "outcome_unknown"
assert fixture.session.query(SendAttempt).one().finished_at is not None
assert fixture.session.get(RecoveryOperation, fixture.operation_id).status == "outcome_unknown"
assert verify_recovery_evidence_chain(fixture.session, fixture.operation_id)
assert fixture.lease.fencing_token > 1
fixture.provider.send_campaign_email_bytes.assert_not_called()
@pytest.mark.parametrize("unsafe", ["production", "foreign_database", "live_process", "wrong_pid", "different_incarnation"])
def test_fixture_claim_proof_rejects_unsafe_target_or_process_without_state_changes(fixture_claim, monkeypatch, unsafe):
fixture = fixture_claim
if unsafe == "production":
monkeypatch.setenv("APP_ENV", "production")
elif unsafe == "foreign_database":
monkeypatch.setenv("DATABASE_URL", "sqlite:////tmp/a-different-database.db")
elif unsafe == "live_process":
fixture.process = SimpleNamespace(pid=12345, returncode=None, poll=lambda: None)
elif unsafe == "wrong_pid":
fixture.process = SimpleNamespace(pid=22222, returncode=-9, poll=lambda: -9)
else:
fixture.node.incarnation = "new-worker-incarnation"
fixture.session.commit()
original_expiry = fixture.lease.expires_at
client = Mock()
with pytest.raises(runner.AcceptanceError):
_recover(fixture, client)
client.post.assert_not_called()
fixture.session.expire_all()
assert fixture.job.send_status == "sending"
assert fixture.job.claim_token == "stale-token"
assert fixture.node.state == "active"
assert fixture.lease.expires_at == original_expiry
assert fixture.session.get(RecoveryOperation, fixture.operation_id).status == "running"
@pytest.mark.parametrize("duplicate_mutates", [False, True])
def test_process_restart_requires_unchanged_claim_before_explicit_recovery(monkeypatch, duplicate_mutates):
interrupted = {"job_count": 1, "send_status_counts": {"sending": 1}, "attempt_status_counts": {"smtp_in_progress": 1}, "unfinished_attempt_count": 1}
recovered = {"job_count": 1, "send_status_counts": {"outcome_unknown": 1}, "attempt_status_counts": {"outcome_unknown": 1}, "unfinished_attempt_count": 0}
states = iter([interrupted, recovered if duplicate_mutates else interrupted, recovered])
first, second = Mock(), Mock()
first.poll.return_value = -9
workers = iter([first, second])
prepared = SimpleNamespace(campaign_id="campaign", version_id="version", public_evidence=lambda: {})
monkeypatch.setattr(runner, "prepare_campaign_scenario", lambda *_args, **_kwargs: prepared)
monkeypatch.setattr(runner, "_start_campaign_worker_task", lambda *_args: next(workers))
monkeypatch.setattr(runner, "_terminate_worker_process", lambda *_args: None)
monkeypatch.setattr(runner, "_wait_for_worker_process", lambda *_args, **_kwargs: None)
client = Mock()
client.post.return_value = _Response(200, {
"queued_count": 1, "skipped_count": 0, "blocked_count": 0, "enqueued_count": 0,
"delivery_mode": "database_queue", "worker_queue_available": False, "dry_run": False,
})
client.get.return_value = _Response(200, {"cards": {}, "status_counts": {"send": {"outcome_unknown": 1}, "imap": {}}})
endpoint = Mock()
endpoint.wait_for_data.return_value = True
endpoint.evidence.return_value = {"connection_count": 1, "accepted_rcpt_commands": 1, "refused_rcpt_commands": 0, "data_transactions": 1}
recover_claim = Mock(return_value={"explicit_fenced_recovery": True})
arguments = dict(
fixture_path=FIXTURE_PATH, profile_id="profile", settings=_settings(), endpoint=endpoint,
snapshot_probe=lambda _: ({}, {}), audit_probe=lambda *_: {"campaign.created": 1, "campaign.validated": 1, "campaign.messages_built": 1, "campaign.queued": 1},
delivery_probe=lambda *_: next(states), worker_job_probe=lambda _: "job", recover_claim=recover_claim,
)
if duplicate_mutates:
with pytest.raises(runner.AcceptanceError, match="without stopped-runtime proof"):
runner.execute_worker_interruption_scenario(client, {}, **arguments)
recover_claim.assert_not_called()
else:
evidence = runner.execute_worker_interruption_scenario(client, {}, **arguments)
assert evidence["interrupted_durable_state"] == evidence["restarted_durable_state"] == interrupted
assert evidence["recovered_durable_state"] == recovered
recover_claim.assert_called_once_with("campaign", "version", first)
def test_direct_task_bootstrap_registers_unique_fixture_owner_and_accepts_read_only_duplicate(monkeypatch):
import os
import sys
from unittest.mock import MagicMock
from govoplan_core import celery_app, db
from govoplan_core.core import runtime_coordination
identity = SimpleNamespace(node_id="fixture-owner")
bind = Mock(return_value=identity)
register = Mock()
session = MagicMock()
database = SimpleNamespace(SessionLocal=MagicMock())
database.SessionLocal.return_value.__enter__.return_value = session
task = SimpleNamespace(run=Mock(return_value={"status": "already_sending"}))
monkeypatch.setattr(celery_app, "_worker_runtime_identity", bind)
monkeypatch.setattr(celery_app, "send_email", task)
monkeypatch.setattr(runtime_coordination, "register_runtime_node", register)
monkeypatch.setattr(db.session, "get_database", lambda: database)
monkeypatch.setattr(sys, "argv", ["fixture-worker", "fixture-job"])
exec(compile(runner.WORKER_TASK_CODE, "<isolated-worker-test>", "exec"), {})
assert bind.call_args.args[0].hostname == f"campaign-acceptance-{os.getpid()}"
register.assert_called_once_with(session, identity, metadata={"acceptance_worker_pid": os.getpid()})
session.commit.assert_called_once()
task.run.assert_called_once_with("fixture-job")
+505
View File
@@ -0,0 +1,505 @@
"""Workerless recovery operates the real job/attempt ledger, never resend actions."""
from contextlib import nullcontext
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import Column, String, Table, create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.db.models import (
Campaign, CampaignVersion, CampaignJob, SendAttempt, ImapAppendAttempt,
PostboxDeliveryAttempt, PrintOutputAttempt, CampaignMessageAction,
)
from govoplan_campaign.backend.delivery_policy import SynchronousSendPolicy
from govoplan_campaign.backend.integrations import MailProfileError, SmtpSendError, ImapAppendError
from govoplan_campaign.backend.routes import delivery as routes
from govoplan_campaign.backend.schemas import CampaignRetryJobsRequest, CampaignSendUnattemptedRequest, CampaignRecoverClaimRequest
from govoplan_campaign.backend.services.delivery_progress import campaign_delivery_progress
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata, recover_stale_delivery_claim, RecoveryStateConflict
from govoplan_campaign.backend.sending import jobs
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.recovery import RecoveryOperation, RecoveryCheckpoint, verify_recovery_evidence_chain
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_core.auth import get_api_principal, ApiPrincipal
from govoplan_core.core.access import PrincipalRef
@dataclass
class _SmtpResult:
accepted_count: int = 1
refused_recipients: dict = field(default_factory=dict)
envelope_recipients: list = field(default_factory=lambda: ["recipient@example.test"])
@pytest.fixture
def recovery(tmp_path, monkeypatch):
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'recovery.db'}")
for name in ("access_users", "access_groups"):
if name not in Base.metadata.tables:
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
Base.metadata.create_all(engine, tables=[Base.metadata.tables[name] for name in ("access_users", "access_groups")] + [
model.__table__ for model in (ChangeSequenceEntry, Campaign, CampaignVersion, CampaignJob,
SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt, CampaignMessageAction,
DistributedLease, RuntimeNode, RecoveryOperation, RecoveryCheckpoint)
])
factory = sessionmaker(engine)
session = factory()
campaign = Campaign(id="campaign", tenant_id="tenant", external_id="C", name="Recovery", current_version_id="version")
version = CampaignVersion(id="version", campaign_id="campaign", version_number=1, raw_json={},
locked_at=datetime.now(timezone.utc), validation_summary={"ok": True},
build_summary={"build_token": "build"}, execution_snapshot_hash="f" * 64,
editor_state={"review_send": {"build_token": "build", "inspection_complete": True, "reviewed_message_keys": ["reviewed"]}})
session.add_all([campaign, version])
session.commit()
snapshot = SimpleNamespace(
mail_profile_id="profile", smtp_server_id="smtp", smtp_credential_id="credential",
smtp_transport_revision="smtp-revision", imap_transport_revision="imap-revision", uses_mail=True,
delivery=SimpleNamespace(retry=SimpleNamespace(max_attempts=3),
rate_limit=SimpleNamespace(messages_per_minute=60), imap_append_sent=SimpleNamespace(enabled=True)),
)
provider = Mock()
provider.wait_for_rate_limit.return_value = None
provider.send_campaign_email_bytes.return_value = _SmtpResult()
provider.campaign_imap_batch.side_effect = lambda **_: nullcontext(SimpleNamespace(connection_count=1, reconnect_count=0))
monkeypatch.setattr(jobs, "get_database", lambda: SimpleNamespace(SessionLocal=factory))
monkeypatch.setattr(jobs, "ensure_execution_snapshot", lambda *_args, **_kw: snapshot)
monkeypatch.setattr(jobs, "_ensure_campaign_approval_gate", Mock())
monkeypatch.setattr(jobs, "_emit_campaign_status_notification", Mock())
monkeypatch.setattr(jobs, "_mark_accepted_job_artifacts", Mock())
monkeypatch.setattr(jobs, "mail_integration", lambda: provider)
monkeypatch.setattr(jobs, "_celery_enabled", lambda: False)
monkeypatch.setattr(jobs, "effective_synchronous_send_policy", lambda *_args, **_kw: SynchronousSendPolicy(2, "system", 500, system_max_recipient_jobs=2))
monkeypatch.setattr(jobs, "_synchronous_smtp_batch_manager", lambda *_args, **_kw: nullcontext(SimpleNamespace(connection_count=1, reconnect_count=0)))
monkeypatch.setattr(jobs, "_send_job_delivery_context", lambda _session, job: SimpleNamespace(
version=version, snapshot=snapshot, message_bytes=b"immutable message",
envelope_from="sender@example.test", envelope_recipients=["recipient@example.test"],
))
monkeypatch.setattr(jobs, "profile_delivery_summary", lambda *_: {"smtp_transport_revision": "smtp-revision"})
yield SimpleNamespace(session=session, factory=factory, engine=engine, campaign=campaign, version=version, provider=provider, snapshot=snapshot)
session.close()
engine.dispose()
def _add(recovery, name, *, status="not_queued", attempt=0, **kwargs):
job = CampaignJob(id=name, tenant_id="tenant", campaign_id="campaign", campaign_version_id="version",
entry_index=recovery.session.query(CampaignJob).count() + 1, entry_id=name,
build_status="built", validation_status="ready", send_status=status,
queue_status="draft", attempt_count=attempt, eml_sha256="e" * 64,
eml_local_path="unused-exact-message.eml",
resolved_recipients={"from": {"email": "sender@example.test"}, "to": [{"email": "recipient@example.test"}]})
for key, value in kwargs.items():
setattr(job, key, value)
recovery.session.add(job)
recovery.session.commit()
return job
def _retry(recovery, **kw):
return jobs.queue_failed_jobs_for_retry(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version", enqueue_celery=False, run_inline=True, **kw)
def _continue(recovery, **kw):
return jobs.queue_unattempted_jobs(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version", enqueue_celery=False, run_inline=True, **kw)
def test_inline_retry_records_canonical_acceptance_and_never_creates_resend_action(recovery):
failed = _add(recovery, "failed", status="failed_temporary", attempt=1)
recovery.session.add(SendAttempt(job_id=failed.id, attempt_number=1, status="failed_temporary", finished_at=datetime.now(timezone.utc)))
recovery.session.commit()
result = _retry(recovery)
assert result["sent_count"] == result["attempted_count"] == 1
with recovery.factory() as check:
current = check.get(CampaignJob, failed.id)
assert current.send_status == "smtp_accepted" and current.attempt_count == 2 and current.imap_status == "pending"
assert [a.status for a in check.query(SendAttempt).order_by(SendAttempt.attempt_number)] == ["failed_temporary", "smtp_accepted"]
assert check.query(CampaignMessageAction).count() == 0
ledger = check.query(RecoveryOperation).one()
assert ledger.status == "succeeded" and verify_recovery_evidence_chain(check, ledger.id)
second = _retry(recovery)
assert second["selected_count"] == 0 and recovery.provider.send_campaign_email_bytes.call_count == 1
def test_continue_is_bounded_and_skips_accepted_excluded_unknown_and_active(recovery):
for name in ("one", "two", "three"):
_add(recovery, name)
_add(recovery, "accepted", status="smtp_accepted", attempt=1)
_add(recovery, "excluded", status="skipped", validation_status="excluded")
_add(recovery, "unknown", status="outcome_unknown", attempt=1)
_add(recovery, "active", status="sending", attempt=1, claim_token="live")
_add(recovery, "claimed", status="claimed", claim_token="live-before-smtp")
first = _continue(recovery)
assert first["selected_count"] == first["sent_count"] == 2 and first["remaining_count"] == 1
assert recovery.session.get(CampaignJob, "three").send_status == "not_queued"
second = _continue(recovery)
assert second["sent_count"] == 1 and second["remaining_count"] == 0
assert recovery.provider.send_campaign_email_bytes.call_count == 3
assert recovery.session.get(CampaignJob, "active").send_status == "sending"
assert recovery.session.get(CampaignJob, "claimed").send_status == "claimed"
assert recovery.session.get(CampaignJob, "unknown").send_status == "outcome_unknown"
def test_continue_drains_queued_unattempted_remainder_but_never_repeats_prior_print_effect(recovery):
_add(recovery, "queued", status="queued", queue_status="queued")
_add(recovery, "printed", status="queued", queue_status="queued", print_attempt_count=1)
result = _continue(recovery)
assert result["sent_count"] == 1
assert recovery.session.get(CampaignJob, "queued").send_status == "smtp_accepted"
assert recovery.session.get(CampaignJob, "printed").send_status == "queued"
assert recovery.provider.send_campaign_email_bytes.call_count == 1
def test_unattempted_review_exception_requires_completed_same_build_review(recovery):
_add(recovery, "reviewed", validation_status="needs_review")
_add(recovery, "not-reviewed", validation_status="needs_review")
result = _continue(recovery)
assert result["selected_count"] == 1 and result["sent_count"] == 1
assert recovery.session.get(CampaignJob, "not-reviewed").send_status == "not_queued"
@pytest.mark.parametrize("reason", ["max_attempts", "permanent", "blocked", "approval", "limit_zero"])
def test_recovery_preserves_delivery_gates(recovery, monkeypatch, reason):
job = _add(recovery, "candidate", status="failed_temporary", attempt=1)
if reason == "max_attempts":
job.attempt_count = 3
elif reason == "permanent":
job.send_status = "failed_permanent"
elif reason == "blocked":
job.validation_status = "blocked"
elif reason == "approval":
monkeypatch.setattr(jobs, "_ensure_campaign_approval_gate", Mock(side_effect=jobs.QueueingError("Approval missing")))
else:
monkeypatch.setattr(jobs, "effective_synchronous_send_policy", lambda *_args, **_kw: SynchronousSendPolicy(0, "system", 500))
recovery.session.commit()
if reason in {"approval", "limit_zero"}:
with pytest.raises(jobs.QueueingError):
_retry(recovery)
else:
assert _retry(recovery)["selected_count"] == 0
assert recovery.provider.send_campaign_email_bytes.call_count == 0
def test_preflight_failure_rolls_back_queue_changes_without_provider_effect(recovery, monkeypatch):
job = _add(recovery, "failed", status="failed_temporary", attempt=1)
class Refuse:
def __enter__(self):
raise MailProfileError("Revoked profile selection")
def __exit__(self, *_):
return False
monkeypatch.setattr(jobs, "_synchronous_smtp_batch_manager", lambda *_args, **_kw: Refuse())
with pytest.raises(jobs.SynchronousSendRejected):
_retry(recovery)
recovery.session.expire_all()
assert job.send_status == "failed_temporary" and job.attempt_count == 1
recovery.provider.send_campaign_email_bytes.assert_not_called()
def test_repeated_worker_task_does_not_mutate_a_live_smtp_claim(recovery):
job = _add(recovery, "live", status="sending", attempt=1, claim_token="live", queue_status="sending")
result = jobs.send_campaign_job(recovery.session, job_id=job.id)
assert result.status == "already_sending"
recovery.session.expire_all()
assert job.send_status == "sending" and job.claim_token == "live"
recovery.provider.send_campaign_email_bytes.assert_not_called()
def test_progress_counts_include_active_and_partition_each_channel_without_loading_jobs(recovery):
_add(recovery, "accepted", status="smtp_accepted", imap_status="appended")
_add(recovery, "imap-active", status="smtp_accepted", imap_status="appending")
_add(recovery, "sending", status="sending", queue_status="sending", imap_status="pending")
_add(recovery, "claimed", status="claimed", imap_status="pending")
_add(recovery, "pending", imap_status="pending")
_add(recovery, "failed", status="failed_temporary", imap_status="failed")
_add(recovery, "unknown", status="outcome_unknown", imap_status="outcome_unknown")
_add(recovery, "paused", status="queued", queue_status="paused", imap_status="pending")
_add(recovery, "cancelled", status="cancelled", imap_status="skipped")
_add(recovery, "excluded", status="skipped", validation_status="excluded", imap_status="skipped")
recovery.session.expunge_all()
loaded = []
event.listen(recovery.session, "loaded_as_persistent", lambda _session, row: loaded.append(row))
progress = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign")
assert progress["total_jobs"] == 10
assert progress["smtp"] == dict(total=9, processed=5, accepted=2, active=2, pending=1, failed=1, outcome_unknown=1, excluded=1, paused=1, cancelled=1)
assert progress["imap"] == dict(total=8, processed=3, appended=1, active=1, pending=4, failed=1, outcome_unknown=1, excluded=2)
assert not any(isinstance(row, CampaignJob) for row in loaded)
assert progress["status_counts"]["send"]["claimed"] == 1
assert "recipient@example.test" not in repr(progress)
def test_progress_scopes_version_and_preserves_partial_multichannel_smtp_acceptance(recovery):
job = _add(recovery, "partial", status="partially_accepted", delivery_channel_policy="mail_and_postbox")
recovery.session.add(SendAttempt(job_id=job.id, attempt_number=1, status="smtp_accepted"))
recovery.session.add(CampaignVersion(id="old", campaign_id="campaign", version_number=2, raw_json={}))
recovery.session.commit()
result = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version")
assert result["smtp"]["accepted"] == 1 and result["smtp"]["failed"] == 0
assert campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="old")["total_jobs"] == 0
with pytest.raises(jobs.QueueingError):
campaign_delivery_progress(recovery.session, tenant_id="other", campaign_id="campaign")
def test_append_processes_only_selected_version_and_uses_one_lazy_batch(recovery, monkeypatch):
first = _add(recovery, "current", status="smtp_accepted", imap_status="pending")
recovery.session.add(CampaignVersion(id="old", campaign_id="campaign", version_number=2, raw_json={}))
recovery.session.commit()
old = _add(recovery, "old-job", status="smtp_accepted", imap_status="pending", campaign_version_id="old")
appended = []
def append(session, *, job_id, dry_run):
appended.append(job_id)
return jobs.AppendSentResult(job_id=job_id, status="appended", attempt_number=1)
monkeypatch.setattr(jobs, "append_sent_for_job", append)
result = jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", enqueue_celery=False, run_inline=True)
assert appended == [first.id] and result["version_id"] == "version" and result["imap_connection_count"] == 1
jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="old", enqueue_celery=False, run_inline=True)
assert appended == [first.id, old.id]
@pytest.mark.parametrize("outcome", ["failed", "outcome_unknown", "raised_unknown"])
def test_inline_imap_summary_keeps_failed_and_unknown_results_distinct(recovery, monkeypatch, outcome):
job = _add(recovery, "imap", status="smtp_accepted", imap_status="pending")
def append(*_args, **_kwargs):
if outcome == "raised_unknown":
raise ImapAppendError("Provider response lost", outcome_unknown=True)
return jobs.AppendSentResult(job_id=job.id, status=outcome, attempt_number=1)
monkeypatch.setattr(jobs, "append_sent_for_job", append)
result = jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", run_inline=True)
assert result["appended_count"] == 0 and result["processed_count"] == 1
assert result["outcome_unknown_count"] == int(outcome != "failed")
assert result["failed_count"] == int(outcome == "failed")
assert result["results"][0]["status"] == ("outcome_unknown" if outcome == "raised_unknown" else outcome)
def _stale_claim(recovery, *, channel="smtp", expired=True, owner="stopped", policy="mail"):
job = _add(recovery, "stale", status="sending" if channel == "smtp" else "smtp_accepted", queue_status="sending" if channel == "smtp" else "draft",
attempt=1, claim_token="stale-token" if channel == "smtp" else None,
imap_status="appending" if channel == "imap" else "pending", imap_claim_token="stale-token" if channel == "imap" else None,
delivery_channel_policy=policy)
identity = process_runtime_identity()
context = SimpleNamespace(version=recovery.version, snapshot=recovery.snapshot, folder="Sent")
begin = jobs._begin_job_delivery_recovery if channel == "smtp" else jobs._begin_imap_append_recovery
started = begin(job=job, context=context, claim_token="stale-token")
recovery.session.expire_all()
lease = recovery.session.query(DistributedLease).one()
lease.holder_node_id = "old-process"
lease.holder_incarnation = "old-incarnation"
lease.expires_at = datetime.now(timezone.utc) + timedelta(minutes=-1 if expired else 10)
node = RuntimeNode(installation_id=identity.installation_id, node_id="old-process",
incarnation="replacement" if owner == "replaced" else "old-incarnation", role="worker", software_version="test", composition_hash="c" * 64,
state="stopped" if owner == "stopped" else "active")
recovery.session.add(node)
attempt = SendAttempt(job_id=job.id, attempt_number=1, status="smtp_in_progress", claim_token="stale-token") if channel == "smtp" else ImapAppendAttempt(job_id=job.id, attempt_number=1, status="appending", claim_token="stale-token")
recovery.session.add(attempt)
recovery.session.commit()
return job, lease, node, started.operation_id
@pytest.mark.parametrize("channel", ["smtp", "imap"])
@pytest.mark.parametrize("owner", ["stopped", "replaced"])
def test_stale_claim_recovery_is_fenced_unknown_then_explicit_evidence_reconciliation(recovery, channel, owner):
job, lease, node, operation_id = _stale_claim(recovery, channel=channel, owner=owner)
metadata = job_recovery_metadata(recovery.session, [job])[job.id][channel]
assert metadata["eligible"] is True
result = recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
channel=channel, expected_revision=metadata["revision"], note="Verified original worker process stopped; inspect provider evidence next.")
recovery.session.commit()
assert result["reconciliation_required"] is True
assert (job.send_status if channel == "smtp" else job.imap_status) == "outcome_unknown"
assert recovery.session.get(RecoveryOperation, operation_id).status == "outcome_unknown"
assert verify_recovery_evidence_chain(recovery.session, operation_id)
assert _retry(recovery)["selected_count"] == 0
decision = "not_sent" if channel == "smtp" else "imap_not_appended"
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, decision=decision, note="Provider logs and target mailbox confirm the effect did not occur.")
assert (job.send_status if channel == "smtp" else job.imap_status) == ("failed_temporary" if channel == "smtp" else "failed")
assert recovery.session.get(RecoveryOperation, operation_id).status == "recovered"
assert verify_recovery_evidence_chain(recovery.session, operation_id)
original_attempt = recovery.session.query(SendAttempt if channel == "smtp" else ImapAppendAttempt).filter_by(job_id=job.id).one()
assert original_attempt.status == ("reconciled_not_sent" if channel == "smtp" else "reconciled_imap_not_appended")
assert "Provider logs" in original_attempt.error_message
recovery.provider.send_campaign_email_bytes.assert_not_called()
@pytest.mark.parametrize("case", ["live_lease", "active_owner", "changed_revision", "wrong_tenant", "missing_ledger"])
def test_claim_recovery_rejects_live_ambiguous_changed_or_unauthorized_state(recovery, case):
job, lease, node, operation_id = _stale_claim(recovery, expired=case != "live_lease", owner="active" if case == "active_owner" else "stopped")
metadata = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
if case == "missing_ledger":
recovery.session.get(RecoveryOperation, operation_id).idempotency_key = "another-operation"
recovery.session.commit()
with pytest.raises(jobs.QueueingError):
recover_stale_delivery_claim(recovery.session, tenant_id="other" if case == "wrong_tenant" else "tenant", campaign_id="campaign", job_id=job.id, channel="smtp", expected_revision="f" * 64 if case == "changed_revision" else metadata["revision"], note="Evidence")
recovery.session.rollback()
assert job.send_status == "sending" and job.claim_token == "stale-token"
recovery.provider.send_campaign_email_bytes.assert_not_called()
class _Principal:
tenant_id = "tenant"
user = SimpleNamespace(id="operator")
def __init__(self, *scopes):
self.scopes = set(scopes)
def has(self, scope):
return scope in self.scopes
@pytest.mark.parametrize("kind", ["retry", "unattempted"])
@pytest.mark.parametrize("missing", ["send", "recipient"])
def test_inline_recovery_requires_send_and_recipient_permission(recovery, kind, missing):
principal = _Principal(*({"campaigns:campaign:send", "campaigns:recipient:read"} - {"campaigns:campaign:send" if missing == "send" else "campaigns:recipient:read"}))
endpoint = routes.retry_campaign_jobs if kind == "retry" else routes.send_unattempted_campaign_jobs
schema = CampaignRetryJobsRequest if kind == "retry" else CampaignSendUnattemptedRequest
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign):
with pytest.raises(HTTPException) as error:
endpoint("campaign", schema(version_id="version", run_inline=True), session=recovery.session, principal=principal)
assert error.value.status_code == 403
recovery.provider.send_campaign_email_bytes.assert_not_called()
def test_claim_recovery_and_audit_are_atomic(recovery):
job, lease, node, operation_id = _stale_claim(recovery)
metadata = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
original_fence = lease.fencing_token
principal = _Principal("campaigns:recipient:read", "campaigns:campaign:reconcile")
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign), patch.object(routes, "audit_from_principal", autospec=True, side_effect=RuntimeError("Audit unavailable")):
with pytest.raises(RuntimeError, match="Audit unavailable"):
routes.recover_campaign_job_claim("campaign", job.id, CampaignRecoverClaimRequest(channel="smtp", expected_revision=metadata["revision"], note="Stopped process verified"), session=recovery.session, principal=principal)
with recovery.factory() as check:
assert check.get(CampaignJob, job.id).send_status == "sending"
assert check.get(CampaignJob, job.id).claim_token == "stale-token"
assert check.get(RecoveryOperation, operation_id).status == "running"
assert check.get(DistributedLease, lease.id).fencing_token == original_fence
assert verify_recovery_evidence_chain(check, operation_id)
def test_existing_worker_queue_path_remains_supported(recovery, monkeypatch):
job = _add(recovery, "retry", status="failed_temporary", attempt=1)
enqueue = Mock()
monkeypatch.setattr(jobs, "_celery_enabled", lambda: True)
monkeypatch.setattr(jobs, "_celery_enqueue_send_job", enqueue)
result = jobs.queue_failed_jobs_for_retry(recovery.session, tenant_id="tenant", campaign_id="campaign", enqueue_celery=True)
assert result["enqueued_count"] == 1 and result["run_inline"] is False
enqueue.assert_called_once_with(job.id)
assert job.send_status == "queued" and job.attempt_count == 1
recovery.provider.send_campaign_email_bytes.assert_not_called()
def test_retry_dry_run_and_foreign_ids_never_change_selected_state(recovery):
job = _add(recovery, "retry", status="failed_temporary", attempt=1)
assert _retry(recovery, dry_run=True)["selected_count"] == 1
assert job.send_status == "failed_temporary" and job.attempt_count == 1
assert _retry(recovery, job_ids=["foreign-job"])["selected_count"] == 0
recovery.provider.send_campaign_email_bytes.assert_not_called()
def test_recovery_public_response_never_returns_provider_diagnostics():
projected = routes._public_recovery_result({"run_inline": True, "campaign_id": "campaign", "version_id": "version",
"selected_count": 1, "remaining_count": 0, "sent_count": 0, "failed_count": 1,
"results": [{"job_id": "job", "status": "failed", "message": "Provider rejected hidden@example.test"}]})
assert projected["selected_count"] == 1
assert projected["results"] == [{"job_id": "job", "status": "failed"}]
assert "hidden@example.test" not in repr(projected)
@pytest.mark.parametrize("result_status", ["failed_temporary", "failed_permanent", "outcome_unknown"])
def test_inline_summary_counts_returned_failures_and_unknown_separately(recovery, monkeypatch, result_status):
job = _add(recovery, "candidate", status="failed_temporary", attempt=1)
monkeypatch.setattr(jobs, "_deliver_job_with_recovery", lambda *_args, **_kw: jobs.SendJobResult(job_id=job.id, status=result_status, attempt_number=2))
result = _retry(recovery)
assert result["failed_count"] == int(result_status != "outcome_unknown")
assert result["outcome_unknown_count"] == int(result_status == "outcome_unknown")
assert result["skipped_count"] == 0
@pytest.mark.parametrize("path,scope,method,payload", [
("delivery-progress?version_id=version", "campaigns:campaign:read", "get", None),
("jobs/retry", "campaigns:campaign:retry", "post", {"version_id": "version", "dry_run": True}),
("jobs/send-unattempted", "campaigns:campaign:queue", "post", {"version_id": "version", "dry_run": True}),
("jobs/job/recover-claim", "campaigns:campaign:reconcile", "post", {"channel": "smtp", "expected_revision": "f" * 64, "note": "Evidence"}),
])
def test_http_scope_dependencies_reject_missing_route_permission(recovery, path, scope, method, payload):
app = FastAPI()
app.include_router(routes.router, prefix="/api/v1")
scopes = {"campaigns:campaign:send", "campaigns:recipient:read"}
actor = SimpleNamespace(id="operator")
app.dependency_overrides[get_api_principal] = lambda: ApiPrincipal(principal=PrincipalRef(account_id="operator", membership_id="operator", tenant_id="tenant", scopes=frozenset(scopes)), user=actor, account=actor)
app.dependency_overrides[get_session] = lambda: recovery.session
with TestClient(app) as client:
response = client.request(method, f"/api/v1/campaigns/campaign/{path}", **({"json": payload} if payload else {}))
assert response.status_code == 403
recovery.provider.send_campaign_email_bytes.assert_not_called()
@pytest.mark.parametrize("channel", ["smtp", "imap"])
def test_accepted_reconciliation_updates_only_matching_original_campaign_operation(recovery, channel):
job, lease, node, operation_id = _stale_claim(recovery, channel=channel)
meta = job_recovery_metadata(recovery.session, [job])[job.id][channel]
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel=channel, expected_revision=meta["revision"], note="Stopped process verified")
recovery.session.commit()
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
decision="smtp_accepted" if channel == "smtp" else "imap_appended", note="Provider log confirms the exact Message-ID was accepted")
with recovery.factory() as check:
assert check.get(RecoveryOperation, operation_id).status == "succeeded"
assert verify_recovery_evidence_chain(check, operation_id)
assert check.query(RecoveryOperation).count() == 1
assert (check.get(CampaignJob, job.id).send_status if channel == "smtp" else check.get(CampaignJob, job.id).imap_status) == ("smtp_accepted" if channel == "smtp" else "appended")
recovery.provider.send_campaign_email_bytes.assert_not_called()
@pytest.mark.parametrize("channel", ["smtp", "imap"])
def test_reconciliation_audit_failure_rolls_back_campaign_attempt_and_core_operation(recovery, channel):
job, lease, node, operation_id = _stale_claim(recovery, channel=channel)
meta = job_recovery_metadata(recovery.session, [job])[job.id][channel]
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel=channel, expected_revision=meta["revision"], note="Stopped process verified")
recovery.session.commit()
from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign), patch.object(routes, "audit_from_principal", autospec=True, side_effect=RuntimeError("Audit unavailable")):
with pytest.raises(RuntimeError, match="Audit unavailable"):
routes.resolve_campaign_job_outcome("campaign", job.id, CampaignResolveOutcomeRequest(decision="not_sent" if channel == "smtp" else "imap_not_appended", note="Verified effect absent"), session=recovery.session, principal=_Principal("campaigns:recipient:read"))
with recovery.factory() as check:
assert check.get(RecoveryOperation, operation_id).status == "outcome_unknown"
assert (check.get(CampaignJob, job.id).send_status if channel == "smtp" else check.get(CampaignJob, job.id).imap_status) == "outcome_unknown"
assert check.query(SendAttempt if channel == "smtp" else ImapAppendAttempt).filter_by(job_id=job.id).one().status == "outcome_unknown"
@pytest.mark.parametrize("channel_policy", ["mail_then_postbox", "postbox_then_mail", "mail_then_print", "mail_and_postbox"])
def test_mixed_delivered_status_without_smtp_attempt_evidence_never_claims_mail_acceptance(recovery, channel_policy):
_add(recovery, "mixed", status="delivered", delivery_channel_policy=channel_policy)
result = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign")
assert result["smtp"]["accepted"] == 0
if channel_policy == "mail_and_postbox":
assert result["smtp"]["outcome_unknown"] == 1
else:
assert result["smtp"]["excluded"] == 1
def test_concurrent_legacy_reconciliation_cannot_overwrite_already_accepted_state(recovery):
job = _add(recovery, "legacy", status="outcome_unknown", attempt=1)
# Keep an old ORM projection, then commit another operator's decision.
with recovery.factory() as concurrent:
current = concurrent.get(CampaignJob, job.id)
current.send_status = "smtp_accepted"
concurrent.commit()
assert job.send_status == "outcome_unknown"
with pytest.raises(jobs.QueueingError, match="changed"):
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, decision="not_sent", note="Stale operator evidence")
recovery.session.rollback()
assert job.send_status == "smtp_accepted"
def test_one_smtp_decision_never_resolves_compound_postbox_operation(recovery):
job, lease, node, operation_id = _stale_claim(recovery, policy="mail_and_postbox")
meta = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel="smtp", expected_revision=meta["revision"], note="Original process stopped")
recovery.session.commit()
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
decision="smtp_accepted", note="SMTP acceptance verified; Postbox remains separately unresolved")
assert job.send_status == "smtp_accepted"
assert recovery.session.get(RecoveryOperation, operation_id).status == "outcome_unknown"
assert verify_recovery_evidence_chain(recovery.session, operation_id)