fix(api): hide campaign operational internals

This commit is contained in:
2026-07-21 12:03:56 +02:00
parent 04bc6c430e
commit ce92499333
9 changed files with 593 additions and 48 deletions

View File

@@ -33,6 +33,14 @@ Files and mail are optional module integrations declared in the campaign manifes
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows. - `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable. - `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
diagnostics endpoint and requires the tenant-level
`campaigns:diagnostic:read` permission. The campaign sender role receives this
permission; tenant-wide administrator scopes continue to grant it through the
standard policy evaluator.
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed. Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
Campaign also provides narrow kernel capabilities so other modules and core Campaign also provides narrow kernel capabilities so other modules and core

View File

@@ -60,6 +60,7 @@ PERMISSIONS = (
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"), _permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"), _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), _permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), _permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
@@ -111,6 +112,7 @@ ROLE_TEMPLATES = (
"campaigns:campaign:send", "campaigns:campaign:send",
"campaigns:campaign:retry", "campaigns:campaign:retry",
"campaigns:campaign:reconcile", "campaigns:campaign:reconcile",
"campaigns:diagnostic:read",
"campaigns:recipient:read", "campaigns:recipient:read",
"campaigns:report:read", "campaigns:report:read",
"campaigns:report:send", "campaigns:report:send",

View File

@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
import copy
import hashlib import hashlib
import json import json
import os
from datetime import UTC, datetime
from email import policy from email import policy
from email.parser import BytesParser from email.parser import BytesParser
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import func from sqlalchemy import func
@@ -44,8 +45,9 @@ class CampaignPersistenceError(RuntimeError):
def _ensure_dirs() -> None: def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR):
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, mode=0o700, exist_ok=True)
directory.chmod(0o700)
@@ -74,7 +76,18 @@ def load_campaign_config_from_json(
def _write_campaign_snapshot(version: CampaignVersion) -> Path: def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs() _ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json" path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o600)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = -1
json.dump(version.raw_json, stream, ensure_ascii=False, indent=2)
finally:
if descriptor >= 0:
os.close(descriptor)
return path return path

View File

@@ -20,6 +20,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt, SendAttempt,
) )
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
class CampaignReportError(RuntimeError): class CampaignReportError(RuntimeError):
@@ -62,10 +63,10 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
"id": version.id, "id": version.id,
"version_number": version.version_number, "version_number": version.version_number,
"schema_version": version.schema_version, "schema_version": version.schema_version,
"source_filename": version.source_filename, "source_filename": public_source_filename(version.source_filename),
"created_at": version.created_at.isoformat() if version.created_at else None, "created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary, "validation_summary": public_campaign_payload(version.validation_summary),
"build_summary": version.build_summary, "build_summary": public_campaign_payload(version.build_summary),
"execution_snapshot_hash": version.execution_snapshot_hash, "execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
} }
@@ -323,8 +324,6 @@ def _job_evidence_row(
"campaign_id": job.campaign_id, "campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id, "campaign_version_id": job.campaign_version_id,
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
"eml_storage_key": job.eml_storage_key,
"eml_local_path": job.eml_local_path,
"from": _address_summary(recipients.get("from")), "from": _address_summary(recipients.get("from")),
"to": _address_summary(recipients.get("to")), "to": _address_summary(recipients.get("to")),
"cc": _address_summary(recipients.get("cc")), "cc": _address_summary(recipients.get("cc")),
@@ -603,8 +602,6 @@ def generate_jobs_csv(
"last_error", "last_error",
"eml_size_bytes", "eml_size_bytes",
"eml_sha256", "eml_sha256",
"eml_storage_key",
"eml_local_path",
"issues_count", "issues_count",
"attachment_config_count", "attachment_config_count",
"matched_file_count", "matched_file_count",

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import copy
from pathlib import Path, PureWindowsPath
from typing import Any
# These fields locate process-local or storage-backend resources, or authorize
# a worker claim. They are useful for tightly controlled diagnostics but are
# not part of the campaign business-data contract.
CAMPAIGN_INTERNAL_RESPONSE_KEYS = frozenset(
{
"campaign_file",
"claim_token",
"eml_local_path",
"eml_path",
"eml_storage_key",
"local_path",
"source_base_path",
"storage_bucket",
"storage_key",
}
)
def public_campaign_payload(value: Any) -> Any:
"""Return a detached payload without infrastructure-only locators."""
if isinstance(value, dict):
return {
key: public_campaign_payload(item)
for key, item in value.items()
if key not in CAMPAIGN_INTERNAL_RESPONSE_KEYS
}
if isinstance(value, list):
return [public_campaign_payload(item) for item in value]
if isinstance(value, tuple):
return tuple(public_campaign_payload(item) for item in value)
return copy.deepcopy(value)
def public_campaign_configuration(value: Any) -> Any:
"""Return campaign JSON without infrastructure locators or mail secrets.
Password-named business fields are intentionally retained. Only the
schema-defined SMTP/IMAP credential paths under ``server`` are secrets.
"""
payload = public_campaign_payload(value)
if not isinstance(payload, dict):
return payload
server = payload.get("server")
if isinstance(server, dict):
for protocol in ("smtp", "imap"):
config = server.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
credentials = server.get("credentials")
if isinstance(credentials, dict):
for protocol in ("smtp", "imap"):
config = credentials.get(protocol)
if isinstance(config, dict):
config.pop("password", None)
_sanitize_configuration_paths(payload)
return payload
def public_source_filename(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return text
windows_path = PureWindowsPath(text)
return windows_path.name if windows_path.is_absolute() or "\\" in text else Path(text).name
def _sanitize_configuration_paths(payload: dict[str, Any]) -> None:
template = payload.get("template")
source = template.get("source") if isinstance(template, dict) else None
if isinstance(source, dict):
for key in ("subject_path", "text_path", "html_path"):
if key in source:
source[key] = _public_configuration_path(source[key])
entries = payload.get("entries")
entry_source = entries.get("source") if isinstance(entries, dict) else None
if isinstance(entry_source, dict) and "path" in entry_source:
entry_source["path"] = _public_configuration_path(entry_source["path"])
attachments = payload.get("attachments")
if isinstance(attachments, dict):
if "base_path" in attachments:
attachments["base_path"] = _public_configuration_path(attachments["base_path"])
base_paths = attachments.get("base_paths")
if isinstance(base_paths, list):
for item in base_paths:
if not isinstance(item, dict):
continue
for key in ("path", "source"):
if key in item:
item[key] = _public_configuration_path(item[key])
_sanitize_attachment_rules(attachments.get("global"))
if isinstance(entries, dict):
inline_entries = entries.get("inline")
if isinstance(inline_entries, list):
for entry in inline_entries:
if isinstance(entry, dict):
_sanitize_attachment_rules(entry.get("attachments"))
defaults = entries.get("defaults")
if isinstance(defaults, dict):
_sanitize_attachment_rules(defaults.get("attachments"))
def _sanitize_attachment_rules(value: Any) -> None:
if not isinstance(value, list):
return
for rule in value:
if isinstance(rule, dict) and "base_dir" in rule:
rule["base_dir"] = _public_configuration_path(rule["base_dir"])
def _public_configuration_path(value: Any) -> Any:
if not isinstance(value, str) or not value.strip():
return value
text = value.strip()
windows_path = PureWindowsPath(text)
path = Path(text)
if windows_path.is_absolute():
return windows_path.name
if path.is_absolute() or text.startswith("~"):
return path.name
return value

View File

@@ -38,6 +38,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobsResponse, CampaignJobsResponse,
CampaignJobsDeltaResponse, CampaignJobsDeltaResponse,
CampaignJobDetailResponse, CampaignJobDetailResponse,
CampaignJobDiagnosticsResponse,
CampaignRetryJobsRequest, CampaignRetryJobsRequest,
CampaignSendJobRequest, CampaignSendJobRequest,
CampaignSendUnattemptedRequest, CampaignSendUnattemptedRequest,
@@ -98,6 +99,7 @@ from govoplan_campaign.backend.db.models import (
) )
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.response_security import public_campaign_payload
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 (
CampaignPersistenceError, CampaignPersistenceError,
@@ -1812,7 +1814,7 @@ def validate_version(
}, },
commit=True, commit=True,
) )
return result return public_campaign_payload(result)
except HTTPException: except HTTPException:
raise raise
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
@@ -1847,7 +1849,7 @@ def build_version(
details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")},
commit=True, commit=True,
) )
return result return public_campaign_payload(result)
except CampaignPersistenceError as exc: except CampaignPersistenceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@@ -1904,14 +1906,81 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
return { return {
**_job_summary_payload(job), **_job_summary_payload(job),
"message_id_header": job.message_id_header, "message_id_header": job.message_id_header,
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"issues": job.issues_snapshot or [], "issues": job.issues_snapshot or [],
"attachments": job.resolved_attachments or [], "attachments": public_campaign_payload(job.resolved_attachments or []),
"resolved_recipients": job.resolved_recipients or {}, "resolved_recipients": job.resolved_recipients or {},
} }
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
*,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = []
for attempt in send_attempts:
payload: dict[str, object] = {
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"smtp_status_code": attempt.smtp_status_code,
"smtp_response": attempt.smtp_response,
"error_type": attempt.error_type,
"error_message": attempt.error_message,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
smtp_payloads.append(payload)
imap_payloads: list[dict[str, object]] = []
for attempt in imap_attempts:
payload = {
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"folder": attempt.folder,
"error_message": attempt.error_message,
"created_at": attempt.created_at,
"updated_at": attempt.updated_at,
}
if include_diagnostics:
payload["claim_token"] = attempt.claim_token
imap_payloads.append(payload)
return {"smtp": smtp_payloads, "imap": imap_payloads}
def _job_diagnostics_payload(
job: CampaignJob,
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
campaign_id=job.campaign_id,
campaign_version_id=job.campaign_version_id,
storage={
"eml_local_path": job.eml_local_path,
"eml_storage_key": job.eml_storage_key,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
},
worker_claim={
"claim_token": job.claim_token,
"claimed_at": job.claimed_at,
"smtp_started_at": job.smtp_started_at,
"outcome_unknown_at": job.outcome_unknown_at,
},
attempts=_job_attempts_payload(
send_attempts,
imap_attempts,
include_diagnostics=True,
),
)
def _review_metadata( def _review_metadata(
session: Session, session: Session,
version: CampaignVersion | None, version: CampaignVersion | None,
@@ -2462,38 +2531,42 @@ def get_job_detail(
) )
return CampaignJobDetailResponse( return CampaignJobDetailResponse(
job=_job_detail_payload(job), job=_job_detail_payload(job),
attempts={ attempts=_job_attempts_payload(send_attempts, imap_attempts),
"smtp": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"claim_token": attempt.claim_token,
"smtp_status_code": attempt.smtp_status_code,
"smtp_response": attempt.smtp_response,
"error_type": attempt.error_type,
"error_message": attempt.error_message,
"started_at": attempt.started_at,
"finished_at": attempt.finished_at,
}
for attempt in send_attempts
],
"imap": [
{
"id": attempt.id,
"attempt_number": attempt.attempt_number,
"status": attempt.status,
"folder": attempt.folder,
"error_message": attempt.error_message,
"created_at": attempt.created_at,
"updated_at": attempt.updated_at,
}
for attempt in imap_attempts
],
},
) )
@router.get(
"/{campaign_id}/jobs/{job_id}/diagnostics",
response_model=CampaignJobDiagnosticsResponse,
)
def get_job_diagnostics(
campaign_id: str,
job_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
):
"""Return infrastructure details only to campaign operators/admins."""
_get_campaign_for_principal(session, campaign_id, principal)
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found")
send_attempts = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.asc())
.all()
)
imap_attempts = (
session.query(ImapAppendAttempt)
.filter(ImapAppendAttempt.job_id == job.id)
.order_by(ImapAppendAttempt.attempt_number.asc())
.all()
)
return _job_diagnostics_payload(job, send_attempts, imap_attempts)
@router.get("/{campaign_id}/summary") @router.get("/{campaign_id}/summary")
def campaign_summary( def campaign_summary(
campaign_id: str, campaign_id: str,

View File

@@ -3,11 +3,17 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.mail.config import ImapConfig, SmtpConfig from govoplan_core.mail.config import ImapConfig, SmtpConfig
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
public_source_filename,
)
class CampaignCreateRequest(BaseModel): class CampaignCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -80,7 +86,6 @@ class CampaignVersionResponse(BaseModel):
version_number: int version_number: int
schema_version: str schema_version: str
source_filename: str | None = None source_filename: str | None = None
source_base_path: str | None = None
workflow_state: str = "editing" workflow_state: str = "editing"
current_flow: str = "manual" current_flow: str = "manual"
current_step: str | None = None current_step: str | None = None
@@ -100,10 +105,25 @@ class CampaignVersionResponse(BaseModel):
execution_snapshot_hash: str | None = None execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None execution_snapshot_at: datetime | None = None
@field_validator("source_filename", mode="before")
@classmethod
def remove_source_directory(cls, value: Any) -> str | None:
return public_source_filename(value)
@field_validator("validation_summary", "build_summary", mode="before")
@classmethod
def remove_internal_summary_fields(cls, value: Any) -> Any:
return public_campaign_payload(value)
class CampaignVersionDetailResponse(CampaignVersionResponse): class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any] raw_json: dict[str, Any]
@field_validator("raw_json", mode="before")
@classmethod
def remove_internal_configuration_fields(cls, value: Any) -> Any:
return public_campaign_configuration(value)
class CampaignPartialValidationResponse(BaseModel): class CampaignPartialValidationResponse(BaseModel):
ok: bool ok: bool
@@ -351,6 +371,15 @@ class CampaignJobDetailResponse(BaseModel):
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignJobDiagnosticsResponse(BaseModel):
job_id: str
campaign_id: str
campaign_version_id: str
storage: dict[str, Any] = Field(default_factory=dict)
worker_claim: dict[str, Any] = Field(default_factory=dict)
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
class CampaignRetryJobsRequest(BaseModel): class CampaignRetryJobsRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
import unittest
from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id from govoplan_campaign.backend.reports.campaigns import _job_evidence_row, _latest_by_job_id
@@ -89,6 +90,8 @@ def test_job_evidence_row_contains_transport_and_message_evidence() -> None:
assert row["latest_smtp_response"] == "2.0.0 queued" assert row["latest_smtp_response"] == "2.0.0 queued"
assert row["latest_imap_status"] == "appended" assert row["latest_imap_status"] == "appended"
assert row["latest_imap_folder"] == "Sent" assert row["latest_imap_folder"] == "Sent"
assert "eml_storage_key" not in row
assert "eml_local_path" not in row
def test_latest_by_job_id_keeps_highest_attempt_number() -> None: def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
@@ -102,3 +105,15 @@ def test_latest_by_job_id_keeps_highest_attempt_number() -> None:
assert latest["job-1"].attempt_number == 3 assert latest["job-1"].attempt_number == 3
assert latest["job-2"].attempt_number == 2 assert latest["job-2"].attempt_number == 2
class CampaignReportProjectionTests(unittest.TestCase):
def test_evidence_projection(self) -> None:
test_job_evidence_row_contains_transport_and_message_evidence()
def test_latest_attempt_projection(self) -> None:
test_latest_by_job_id_keeps_highest_attempt_number()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,274 @@
from __future__ import annotations
import stat
import unittest
from datetime import UTC, datetime
from types import SimpleNamespace
from govoplan_campaign.backend.response_security import public_campaign_payload
from govoplan_campaign.backend.router import (
_job_attempts_payload,
_job_detail_payload,
_job_diagnostics_payload,
)
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionResponse
def _now() -> datetime:
return datetime(2026, 7, 21, 12, 0, tzinfo=UTC)
def _job() -> SimpleNamespace:
return SimpleNamespace(
id="job-1",
tenant_id="tenant-1",
campaign_id="campaign-1",
campaign_version_id="version-1",
entry_index=1,
entry_id="recipient-1",
recipient_email="person@example.test",
subject="Subject",
message_id_header="<message@example.test>",
build_status="built",
validation_status="ready",
queue_status="claimed",
send_status="sending",
imap_status="pending",
eml_size_bytes=123,
eml_sha256="sha256",
eml_local_path="/runtime/campaign/job-1.eml",
eml_storage_key="campaign/job-1.eml",
claim_token="job-claim-secret",
attempt_count=1,
last_error=None,
queued_at=_now(),
claimed_at=_now(),
smtp_started_at=_now(),
outcome_unknown_at=None,
sent_at=None,
created_at=_now(),
updated_at=_now(),
issues_snapshot=[],
resolved_attachments=[
{
"filename": "public.pdf",
"local_path": "/tmp/materialized/public.pdf",
"storage_key": "private/blob-key",
}
],
resolved_recipients={"to": [{"email": "person@example.test"}]},
)
def _smtp_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="smtp-attempt-1",
attempt_number=1,
status="started",
claim_token="attempt-claim-secret",
smtp_status_code=None,
smtp_response=None,
error_type=None,
error_message=None,
started_at=_now(),
finished_at=None,
)
def _imap_attempt() -> SimpleNamespace:
return SimpleNamespace(
id="imap-attempt-1",
attempt_number=1,
status="claimed",
claim_token="imap-claim-secret",
folder="Sent",
error_message=None,
created_at=_now(),
updated_at=_now(),
)
def test_public_payload_recursively_removes_infrastructure_locators() -> None:
source = {
"campaign_file": "/tmp/campaign.json",
"messages": [
{
"subject": "Public",
"eml_path": "/tmp/message.eml",
"managed": {"storage_bucket": "private", "storage_key": "object"},
}
],
}
result = public_campaign_payload(source)
assert result == {"messages": [{"subject": "Public", "managed": {}}]}
assert source["messages"][0]["eml_path"] == "/tmp/message.eml"
def test_version_response_omits_source_base_path_and_sanitizes_summaries() -> None:
version = SimpleNamespace(
id="version-1",
campaign_id="campaign-1",
version_number=1,
schema_version="1",
source_filename="/srv/govoplan/imports/campaign.json",
source_base_path="/private/import/path",
workflow_state="editing",
current_flow="manual",
current_step=None,
is_complete=False,
editor_state={},
autosaved_at=None,
published_at=None,
locked_at=None,
locked_by_user_id=None,
user_lock_state=None,
user_locked_at=None,
user_locked_by_user_id=None,
created_at=_now(),
updated_at=_now(),
validation_summary={"campaign_file": "/tmp/campaign.json", "ok": True},
build_summary={"messages": [{"eml_path": "/tmp/message.eml", "subject": "Public"}]},
execution_snapshot_hash=None,
execution_snapshot_at=None,
)
payload = CampaignVersionResponse.model_validate(version).model_dump()
assert "source_base_path" not in payload
assert payload["source_filename"] == "campaign.json"
assert payload["validation_summary"] == {"ok": True}
assert payload["build_summary"] == {"messages": [{"subject": "Public"}]}
detail = CampaignVersionDetailResponse.model_validate(
SimpleNamespace(
**version.__dict__,
raw_json={
"campaign": {"title": "Public"},
"files": [{"storage_key": "private/object", "filename": "public.pdf"}],
"server": {
"smtp": {"host": "smtp.example.invalid", "password": "smtp-secret"},
"imap": {"host": "imap.example.invalid", "password": "imap-secret"},
"credentials": {
"smtp": {"username": "sender", "password": "legacy-smtp-secret"},
"imap": {"username": "archive", "password": "legacy-imap-secret"},
},
},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "/srv/govoplan/templates/subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "C:\\imports\\recipients.csv"}},
"attachments": {
"base_path": "/srv/govoplan/attachments",
"base_paths": [
{"name": "Shared", "path": "/mnt/shared/campaign", "source": "/mnt/shared"}
],
"global": [{"base_dir": "/srv/govoplan/attachments/global", "file_filter": "*.pdf"}],
},
},
)
).model_dump()
assert detail["raw_json"] == {
"campaign": {"title": "Public"},
"files": [{"filename": "public.pdf"}],
"server": {
"smtp": {"host": "smtp.example.invalid"},
"imap": {"host": "imap.example.invalid"},
"credentials": {
"smtp": {"username": "sender"},
"imap": {"username": "archive"},
},
},
"archives": [{"password": "business-zip-password"}],
"template": {
"source": {
"subject_path": "subject.txt",
"html_path": "templates/body.html",
}
},
"entries": {"source": {"type": "csv", "path": "recipients.csv"}},
"attachments": {
"base_path": "attachments",
"base_paths": [{"name": "Shared", "path": "campaign", "source": "shared"}],
"global": [{"base_dir": "global", "file_filter": "*.pdf"}],
},
}
def test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics() -> None:
job_payload = _job_detail_payload(_job()) # type: ignore[arg-type]
attempts = _job_attempts_payload([_smtp_attempt()], [_imap_attempt()]) # type: ignore[list-item]
assert "eml_local_path" not in job_payload
assert "eml_storage_key" not in job_payload
assert job_payload["attachments"] == [{"filename": "public.pdf"}]
assert "claim_token" not in attempts["smtp"][0]
assert "claim_token" not in attempts["imap"][0]
def test_operator_diagnostics_include_claim_and_storage_details() -> None:
diagnostics = _job_diagnostics_payload( # type: ignore[arg-type, list-item]
_job(),
[_smtp_attempt()],
[_imap_attempt()],
).model_dump()
assert diagnostics["storage"]["eml_local_path"] == "/runtime/campaign/job-1.eml"
assert diagnostics["storage"]["eml_storage_key"] == "campaign/job-1.eml"
assert diagnostics["worker_claim"]["claim_token"] == "job-claim-secret"
assert diagnostics["attempts"]["smtp"][0]["claim_token"] == "attempt-claim-secret"
assert diagnostics["attempts"]["imap"][0]["claim_token"] == "imap-claim-secret"
def test_diagnostics_permission_is_operator_only_by_default() -> None:
from govoplan_campaign.backend.manifest import PERMISSIONS, ROLE_TEMPLATES
permission_scopes = {permission.scope for permission in PERMISSIONS}
role_permissions = {role.slug: set(role.permissions) for role in ROLE_TEMPLATES}
assert "campaigns:diagnostic:read" in permission_scopes
assert "campaigns:diagnostic:read" in role_permissions["campaign_sender"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_manager"]
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None:
from govoplan_campaign.backend.persistence import campaigns as persistence
snapshots = tmp_path / "snapshots"
output = tmp_path / "generated"
monkeypatch.setattr(persistence, "CAMPAIGN_SNAPSHOT_DIR", snapshots)
monkeypatch.setattr(persistence, "BUILD_OUTPUT_DIR", output)
path = persistence._write_campaign_snapshot( # type: ignore[arg-type]
SimpleNamespace(id="version-secret", raw_json={"server": {"smtp": {"password": "secret"}}})
)
assert stat.S_IMODE(path.stat().st_mode) == 0o600
assert stat.S_IMODE(snapshots.stat().st_mode) == 0o700
assert stat.S_IMODE(output.stat().st_mode) == 0o700
class ResponseSecurityTests(unittest.TestCase):
def test_public_payload(self) -> None:
test_public_payload_recursively_removes_infrastructure_locators()
def test_version_response(self) -> None:
test_version_response_omits_source_base_path_and_sanitizes_summaries()
def test_ordinary_job_response(self) -> None:
test_ordinary_job_detail_and_attempts_do_not_expose_diagnostics()
def test_operator_diagnostics(self) -> None:
test_operator_diagnostics_include_claim_and_storage_details()
def test_operator_permission(self) -> None:
test_diagnostics_permission_is_operator_only_by_default()
if __name__ == "__main__":
unittest.main()