Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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: {}})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"]'}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user