diff --git a/README.md b/README.md index 8d75abf..df0e8bb 100644 --- a/README.md +++ b/README.md @@ -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-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. Campaign also provides narrow kernel capabilities so other modules and core diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index cfeb7e3..d975482 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -60,6 +60,7 @@ PERMISSIONS = ( _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: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:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), @@ -111,6 +112,7 @@ ROLE_TEMPLATES = ( "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:reconcile", + "campaigns:diagnostic:read", "campaigns:recipient:read", "campaigns:report:read", "campaigns:report:send", diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 9b6edb1..f92ea66 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -1,13 +1,14 @@ from __future__ import annotations +import copy import hashlib import json +import os +from datetime import UTC, datetime from email import policy from email.parser import BytesParser from pathlib import Path from typing import Any -import copy -from datetime import UTC, datetime from uuid import uuid4 from sqlalchemy import func @@ -44,8 +45,9 @@ class CampaignPersistenceError(RuntimeError): def _ensure_dirs() -> None: - CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) - BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + for directory in (CAMPAIGN_SNAPSHOT_DIR, BUILD_OUTPUT_DIR): + 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: _ensure_dirs() 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 diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index b7de0fb..5e9cd10 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -20,6 +20,7 @@ from govoplan_campaign.backend.db.models import ( SendAttempt, ) from govoplan_campaign.backend.sending.execution import ExecutionSnapshot +from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename class CampaignReportError(RuntimeError): @@ -62,10 +63,10 @@ def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None: "id": version.id, "version_number": version.version_number, "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, - "validation_summary": version.validation_summary, - "build_summary": version.build_summary, + "validation_summary": public_campaign_payload(version.validation_summary), + "build_summary": public_campaign_payload(version.build_summary), "execution_snapshot_hash": version.execution_snapshot_hash, "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_version_id": job.campaign_version_id, "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")), "to": _address_summary(recipients.get("to")), "cc": _address_summary(recipients.get("cc")), @@ -603,8 +602,6 @@ def generate_jobs_csv( "last_error", "eml_size_bytes", "eml_sha256", - "eml_storage_key", - "eml_local_path", "issues_count", "attachment_config_count", "matched_file_count", diff --git a/src/govoplan_campaign/backend/response_security.py b/src/govoplan_campaign/backend/response_security.py new file mode 100644 index 0000000..91f0117 --- /dev/null +++ b/src/govoplan_campaign/backend/response_security.py @@ -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 diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index c541169..b8f47f9 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -38,6 +38,7 @@ from govoplan_campaign.backend.schemas import ( CampaignJobsResponse, CampaignJobsDeltaResponse, CampaignJobDetailResponse, + CampaignJobDiagnosticsResponse, CampaignRetryJobsRequest, CampaignSendJobRequest, CampaignSendUnattemptedRequest, @@ -98,6 +99,7 @@ from govoplan_campaign.backend.db.models import ( ) 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.response_security import public_campaign_payload from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email from govoplan_campaign.backend.persistence.campaigns import ( CampaignPersistenceError, @@ -1812,7 +1814,7 @@ def validate_version( }, commit=True, ) - return result + return public_campaign_payload(result) except HTTPException: raise 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")}, commit=True, ) - return result + return public_campaign_payload(result) except CampaignPersistenceError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: @@ -1904,14 +1906,81 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]: return { **_job_summary_payload(job), "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 [], - "attachments": job.resolved_attachments or [], + "attachments": public_campaign_payload(job.resolved_attachments 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( session: Session, version: CampaignVersion | None, @@ -2462,38 +2531,42 @@ def get_job_detail( ) return CampaignJobDetailResponse( job=_job_detail_payload(job), - 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 - ], - }, + attempts=_job_attempts_payload(send_attempts, 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") def campaign_summary( campaign_id: str, diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index 056f371..2671d30 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -3,11 +3,17 @@ from __future__ import annotations from datetime import datetime 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.mail.config import ImapConfig, SmtpConfig +from govoplan_campaign.backend.response_security import ( + public_campaign_configuration, + public_campaign_payload, + public_source_filename, +) + class CampaignCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -80,7 +86,6 @@ class CampaignVersionResponse(BaseModel): version_number: int schema_version: str source_filename: str | None = None - source_base_path: str | None = None workflow_state: str = "editing" current_flow: str = "manual" current_step: str | None = None @@ -100,10 +105,25 @@ class CampaignVersionResponse(BaseModel): execution_snapshot_hash: str | 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): 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): ok: bool @@ -351,6 +371,15 @@ class CampaignJobDetailResponse(BaseModel): 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): model_config = ConfigDict(extra="forbid") diff --git a/tests/test_campaign_reports.py b/tests/test_campaign_reports.py index 8969f67..030a602 100644 --- a/tests/test_campaign_reports.py +++ b/tests/test_campaign_reports.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import UTC, datetime from types import SimpleNamespace +import unittest 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_imap_status"] == "appended" 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: @@ -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-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() diff --git a/tests/test_response_security.py b/tests/test_response_security.py new file mode 100644 index 0000000..af639fe --- /dev/null +++ b/tests/test_response_security.py @@ -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="", + 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()