fix(api): hide campaign operational internals
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
134
src/govoplan_campaign/backend/response_security.py
Normal file
134
src/govoplan_campaign/backend/response_security.py
Normal 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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
274
tests/test_response_security.py
Normal file
274
tests/test_response_security.py
Normal 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()
|
||||||
Reference in New Issue
Block a user