fix(campaign): sanitize synchronous send results
This commit is contained in:
@@ -40,6 +40,51 @@ CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_SEND_NOW_RESULT_KEYS = (
|
||||||
|
"campaign_id",
|
||||||
|
"version_id",
|
||||||
|
"attempted_count",
|
||||||
|
"sent_count",
|
||||||
|
"failed_count",
|
||||||
|
"outcome_unknown_count",
|
||||||
|
"skipped_count",
|
||||||
|
"preflight_count",
|
||||||
|
"delivery_mode",
|
||||||
|
"dry_run",
|
||||||
|
)
|
||||||
|
_SEND_NOW_JOB_RESULT_KEYS = (
|
||||||
|
"campaign_id",
|
||||||
|
"version_id",
|
||||||
|
"job_id",
|
||||||
|
"status",
|
||||||
|
"attempt_number",
|
||||||
|
"dry_run",
|
||||||
|
"queued_count",
|
||||||
|
"skipped_count",
|
||||||
|
"blocked_count",
|
||||||
|
"enqueued_count",
|
||||||
|
"delivery_mode",
|
||||||
|
"worker_queue_available",
|
||||||
|
)
|
||||||
|
_SYNCHRONOUS_POLICY_KEYS = (
|
||||||
|
"max_recipient_jobs",
|
||||||
|
"source",
|
||||||
|
"deployment_max_recipient_jobs",
|
||||||
|
"tenant_max_recipient_jobs",
|
||||||
|
)
|
||||||
|
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
||||||
|
_BUILD_SUMMARY_KEYS = (
|
||||||
|
"built_count",
|
||||||
|
"build_failed_count",
|
||||||
|
"ready_count",
|
||||||
|
"warning_count",
|
||||||
|
"needs_review_count",
|
||||||
|
"blocked_count",
|
||||||
|
"excluded_count",
|
||||||
|
"inactive_count",
|
||||||
|
"queueable_count",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
|
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
|
||||||
"""Return a detached payload without infrastructure-only locators."""
|
"""Return a detached payload without infrastructure-only locators."""
|
||||||
@@ -83,6 +128,59 @@ def public_delivery_result_message(
|
|||||||
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
|
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
|
||||||
|
|
||||||
|
|
||||||
|
def public_send_campaign_now_result(
|
||||||
|
value: dict[str, Any],
|
||||||
|
*,
|
||||||
|
validation_summary: dict[str, Any],
|
||||||
|
build_summary: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Project synchronous delivery into its recipient-authorized public contract.
|
||||||
|
|
||||||
|
Per-job provider messages are deliberately omitted. They can contain SMTP
|
||||||
|
diagnostics or refused envelope addresses and belong only in restricted
|
||||||
|
diagnostics backed by persisted job state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
|
||||||
|
policy = value.get("synchronous_send_policy")
|
||||||
|
result["synchronous_send_policy"] = _selected_payload(
|
||||||
|
policy if isinstance(policy, dict) else {},
|
||||||
|
_SYNCHRONOUS_POLICY_KEYS,
|
||||||
|
)
|
||||||
|
rows = value.get("results")
|
||||||
|
if isinstance(rows, list):
|
||||||
|
result["results"] = [
|
||||||
|
_selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS)
|
||||||
|
for row in rows
|
||||||
|
if isinstance(row, dict)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
result["results"] = []
|
||||||
|
result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS)
|
||||||
|
result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Return aggregate-only evidence for a synchronous Campaign send audit."""
|
||||||
|
|
||||||
|
details = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
|
||||||
|
policy = value.get("synchronous_send_policy")
|
||||||
|
details["synchronous_send_policy"] = _selected_payload(
|
||||||
|
policy if isinstance(policy, dict) else {},
|
||||||
|
_SYNCHRONOUS_POLICY_KEYS,
|
||||||
|
)
|
||||||
|
return details
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
key: copy.deepcopy(value[key])
|
||||||
|
for key in keys
|
||||||
|
if key in value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def public_campaign_configuration(value: Any) -> Any:
|
def public_campaign_configuration(value: Any) -> Any:
|
||||||
"""Return campaign JSON without infrastructure locators or mail secrets.
|
"""Return campaign JSON without infrastructure locators or mail secrets.
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ from govoplan_campaign.backend.reports.aggregate import (
|
|||||||
from govoplan_campaign.backend.response_security import (
|
from govoplan_campaign.backend.response_security import (
|
||||||
public_campaign_payload,
|
public_campaign_payload,
|
||||||
public_delivery_result_message,
|
public_delivery_result_message,
|
||||||
|
public_send_campaign_now_result,
|
||||||
|
send_campaign_now_audit_details,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
@@ -3580,7 +3582,7 @@ def send_campaign_now_endpoint(
|
|||||||
detail="Campaign version must be built before dry-run or sending.",
|
detail="Campaign version must be built before dry-run or sending.",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = send_campaign_now(
|
delivery_result = send_campaign_now(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
@@ -3590,18 +3592,21 @@ def send_campaign_now_endpoint(
|
|||||||
use_rate_limit=payload.use_rate_limit,
|
use_rate_limit=payload.use_rate_limit,
|
||||||
enqueue_imap_task=payload.enqueue_imap_task,
|
enqueue_imap_task=payload.enqueue_imap_task,
|
||||||
).as_dict()
|
).as_dict()
|
||||||
result["validation"] = validation_result
|
response_result = public_send_campaign_now_result(
|
||||||
result["build"] = build_result
|
delivery_result,
|
||||||
|
validation_summary=validation_result,
|
||||||
|
build_summary=build_result,
|
||||||
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
|
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
|
||||||
object_type="campaign",
|
object_type="campaign",
|
||||||
object_id=campaign_id,
|
object_id=campaign_id,
|
||||||
details=result,
|
details=send_campaign_now_audit_details(delivery_result),
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return SendCampaignNowResponse(result=result)
|
return SendCampaignNowResponse(result=response_result)
|
||||||
except SynchronousSendRejected as exc:
|
except SynchronousSendRejected as exc:
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
|
|||||||
133
tests/test_send_now_response_security.py
Normal file
133
tests/test_send_now_response_security.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
|
||||||
|
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() -> None:
|
||||||
|
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||||
|
version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
raw_json={},
|
||||||
|
locked_at=datetime(2026, 7, 22, tzinfo=UTC),
|
||||||
|
validation_summary={
|
||||||
|
"ok": True,
|
||||||
|
"error_count": 0,
|
||||||
|
"warning_count": 1,
|
||||||
|
"issues": [{"message": "provider-secret validation detail"}],
|
||||||
|
},
|
||||||
|
build_summary={
|
||||||
|
"built_count": 2,
|
||||||
|
"build_failed_count": 0,
|
||||||
|
"ready_count": 2,
|
||||||
|
"warning_count": 1,
|
||||||
|
"messages": [{"recipient": "refused-recipient@example.test"}],
|
||||||
|
"build_token": "provider-secret-build-token",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
delivery = SendCampaignNowResult(
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
version_id="version-1",
|
||||||
|
attempted_count=2,
|
||||||
|
sent_count=1,
|
||||||
|
failed_count=1,
|
||||||
|
outcome_unknown_count=0,
|
||||||
|
skipped_count=0,
|
||||||
|
preflight_count=2,
|
||||||
|
synchronous_send_policy={
|
||||||
|
"max_recipient_jobs": 25,
|
||||||
|
"source": "deployment_default",
|
||||||
|
"deployment_max_recipient_jobs": 25,
|
||||||
|
"tenant_max_recipient_jobs": None,
|
||||||
|
"provider_diagnostic": "provider-secret-policy",
|
||||||
|
},
|
||||||
|
results=[
|
||||||
|
{
|
||||||
|
"job_id": "job-failed",
|
||||||
|
"status": "failed",
|
||||||
|
"attempt_number": 1,
|
||||||
|
"message": "provider-secret smtp.internal.example",
|
||||||
|
"recipient_email": "private@example.test",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"job_id": "job-refused",
|
||||||
|
"status": "smtp_accepted",
|
||||||
|
"attempt_number": 1,
|
||||||
|
"message": (
|
||||||
|
"SMTP accepted 1/2 envelope recipient(s); refused recipients: "
|
||||||
|
'{"refused-recipient@example.test": [550, "not allowed"]}'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None)
|
||||||
|
audit = Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(router, "_require_permission"),
|
||||||
|
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
||||||
|
patch.object(router, "_get_version_for_tenant", return_value=version),
|
||||||
|
patch.object(router, "_require_mail_profile_use_if_needed"),
|
||||||
|
patch.object(router, "is_user_locked_version", return_value=False),
|
||||||
|
patch.object(router, "send_campaign_now", return_value=delivery),
|
||||||
|
patch.object(router, "audit_from_principal", audit),
|
||||||
|
):
|
||||||
|
response = router.send_campaign_now_endpoint(
|
||||||
|
"campaign-1",
|
||||||
|
SendCampaignNowRequest(),
|
||||||
|
session=Mock(),
|
||||||
|
principal=principal, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.model_dump(mode="json")["result"]
|
||||||
|
assert payload["results"] == [
|
||||||
|
{"job_id": "job-failed", "status": "failed", "attempt_number": 1},
|
||||||
|
{"job_id": "job-refused", "status": "smtp_accepted", "attempt_number": 1},
|
||||||
|
]
|
||||||
|
assert payload["validation"] == {"ok": True, "error_count": 0, "warning_count": 1}
|
||||||
|
assert payload["build"] == {
|
||||||
|
"built_count": 2,
|
||||||
|
"build_failed_count": 0,
|
||||||
|
"ready_count": 2,
|
||||||
|
"warning_count": 1,
|
||||||
|
}
|
||||||
|
assert payload["synchronous_send_policy"] == {
|
||||||
|
"max_recipient_jobs": 25,
|
||||||
|
"source": "deployment_default",
|
||||||
|
"deployment_max_recipient_jobs": 25,
|
||||||
|
"tenant_max_recipient_jobs": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
audit_details = audit.call_args.kwargs["details"]
|
||||||
|
assert set(audit_details) == {
|
||||||
|
"campaign_id",
|
||||||
|
"version_id",
|
||||||
|
"attempted_count",
|
||||||
|
"sent_count",
|
||||||
|
"failed_count",
|
||||||
|
"outcome_unknown_count",
|
||||||
|
"skipped_count",
|
||||||
|
"preflight_count",
|
||||||
|
"delivery_mode",
|
||||||
|
"dry_run",
|
||||||
|
"synchronous_send_policy",
|
||||||
|
}
|
||||||
|
assert "results" not in audit_details
|
||||||
|
assert "validation" not in audit_details
|
||||||
|
assert "build" not in audit_details
|
||||||
|
|
||||||
|
serialized = repr({"response": payload, "audit": audit_details})
|
||||||
|
for forbidden in (
|
||||||
|
"provider-secret",
|
||||||
|
"smtp.internal.example",
|
||||||
|
"private@example.test",
|
||||||
|
"refused-recipient@example.test",
|
||||||
|
"not allowed",
|
||||||
|
):
|
||||||
|
assert forbidden not in serialized
|
||||||
Reference in New Issue
Block a user