feat: govern shared campaign artifact storage
This commit is contained in:
@@ -92,9 +92,6 @@ class CampaignOptimisticConcurrencyTests(unittest.TestCase):
|
||||
"govoplan_campaign.backend.persistence.versions._updated_runtime_json",
|
||||
side_effect=lambda _session, **kwargs: kwargs["raw_json"],
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.persistence.versions._write_campaign_snapshot"
|
||||
),
|
||||
):
|
||||
return update_campaign_version(
|
||||
session,
|
||||
|
||||
@@ -2,17 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
|
||||
from govoplan_campaign.backend.operational_checks import generated_eml_storage_check
|
||||
|
||||
|
||||
def test_generated_eml_probe_reports_node_local_boundary(tmp_path) -> None:
|
||||
with patch(
|
||||
"govoplan_campaign.backend.operational_checks.BUILD_OUTPUT_DIR",
|
||||
tmp_path,
|
||||
"govoplan_campaign.backend.operational_checks.configured_storage_backend",
|
||||
return_value=LocalFilesystemStorageBackend(tmp_path),
|
||||
):
|
||||
result = generated_eml_storage_check()
|
||||
|
||||
assert result.state == "warning"
|
||||
assert "node-local" in result.detail
|
||||
assert list(tmp_path.rglob("*.probe")) == []
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
@@ -307,20 +306,18 @@ def test_diagnostics_permission_is_operator_only_by_default() -> None:
|
||||
assert "campaigns:diagnostic:read" not in role_permissions["campaign_reviewer"]
|
||||
|
||||
|
||||
def test_campaign_snapshot_with_inline_credentials_is_owner_only(tmp_path, monkeypatch) -> None:
|
||||
def test_database_campaign_config_is_not_mirrored_to_node_local_disk(tmp_path) -> 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"}}})
|
||||
path = persistence._campaign_reference_path( # type: ignore[arg-type]
|
||||
SimpleNamespace(
|
||||
source_base_path=str(tmp_path),
|
||||
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
|
||||
assert path == tmp_path / "campaign.json"
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
class ResponseSecurityTests(unittest.TestCase):
|
||||
|
||||
@@ -79,13 +79,13 @@ class ServerCampaignPathSecurityTests(unittest.TestCase):
|
||||
"govoplan_campaign.backend.persistence.campaigns.files_integration",
|
||||
return_value=SimpleNamespace(available=True),
|
||||
),
|
||||
patch("govoplan_campaign.backend.persistence.campaigns._write_campaign_snapshot") as write_snapshot,
|
||||
patch("govoplan_campaign.backend.persistence.campaigns._campaign_reference_path") as reference_path,
|
||||
patch("govoplan_campaign.backend.persistence.campaigns.load_campaign_config_from_json") as load_config,
|
||||
):
|
||||
with self.assertRaisesRegex(CampaignPathSecurityError, "template source paths"):
|
||||
load_version_config(session, "version-1") # type: ignore[arg-type]
|
||||
|
||||
write_snapshot.assert_not_called()
|
||||
reference_path.assert_not_called()
|
||||
load_config.assert_not_called()
|
||||
|
||||
def test_source_base_symlink_escape_is_rejected(self) -> None:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.object_storage import (
|
||||
LocalFilesystemStorageBackend,
|
||||
StorageBackendError,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
_persist_built_eml_artifacts,
|
||||
)
|
||||
from govoplan_campaign.backend.retention import _apply_eml_retention
|
||||
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
|
||||
|
||||
|
||||
class _FailingStorage:
|
||||
name = "test"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.objects: dict[str, bytes] = {}
|
||||
self.deleted: list[str] = []
|
||||
|
||||
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
|
||||
if self.objects:
|
||||
raise StorageBackendError("object service unavailable")
|
||||
self.objects[key] = data
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self.deleted.append(key)
|
||||
self.objects.pop(key, None)
|
||||
|
||||
|
||||
def test_partial_campaign_artifact_write_is_compensated(tmp_path: Path) -> None:
|
||||
first = tmp_path / "first.eml"
|
||||
second = tmp_path / "second.eml"
|
||||
first.write_bytes(b"Message-ID: <first@example.test>\r\n\r\nfirst")
|
||||
second.write_bytes(b"Message-ID: <second@example.test>\r\n\r\nsecond")
|
||||
messages = [
|
||||
SimpleNamespace(
|
||||
draft=SimpleNamespace(
|
||||
entry_index=1,
|
||||
eml_path=str(first),
|
||||
eml_size_bytes=None,
|
||||
)
|
||||
),
|
||||
SimpleNamespace(
|
||||
draft=SimpleNamespace(
|
||||
entry_index=2,
|
||||
eml_path=str(second),
|
||||
eml_size_bytes=None,
|
||||
)
|
||||
),
|
||||
]
|
||||
storage = _FailingStorage()
|
||||
|
||||
with pytest.raises(CampaignPersistenceError, match="could not be persisted"):
|
||||
_persist_built_eml_artifacts(
|
||||
storage=storage, # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
build_id="build-1",
|
||||
built_messages=messages,
|
||||
)
|
||||
|
||||
assert storage.objects == {}
|
||||
assert len(storage.deleted) == 1
|
||||
|
||||
|
||||
def test_worker_loads_and_verifies_campaign_eml_from_object_storage(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
payload = b"Message-ID: <message@example.test>\r\n\r\nbody"
|
||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||
storage.put_bytes("campaign-artifacts/message.eml", payload)
|
||||
job = SimpleNamespace(
|
||||
id="job-1",
|
||||
eml_storage_key="campaign-artifacts/message.eml",
|
||||
eml_local_path=None,
|
||||
eml_size_bytes=len(payload),
|
||||
eml_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
message_id_header="<message@example.test>",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.sending.jobs.configured_storage_backend",
|
||||
return_value=storage,
|
||||
):
|
||||
assert _load_eml_bytes_for_job(job) == payload
|
||||
|
||||
|
||||
def test_retention_preserves_reference_when_object_store_is_unavailable() -> None:
|
||||
job = SimpleNamespace(
|
||||
campaign_id="campaign-1",
|
||||
updated_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
queue_status="draft",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="appended",
|
||||
eml_local_path=None,
|
||||
eml_storage_key="campaign-artifacts/message.eml",
|
||||
)
|
||||
session = MagicMock()
|
||||
session.query.return_value.filter.return_value.order_by.return_value.all.return_value = [
|
||||
job
|
||||
]
|
||||
storage = MagicMock()
|
||||
storage.exists.side_effect = StorageBackendError("object service unavailable")
|
||||
|
||||
result = _apply_eml_retention(
|
||||
session,
|
||||
dry_run=False,
|
||||
now=datetime.now(timezone.utc),
|
||||
policy_for_campaign_id=lambda _campaign_id: SimpleNamespace(
|
||||
generated_eml_retention_days=1
|
||||
),
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
assert result["delete_failed"] == 1
|
||||
assert result["metadata_cleared"] == 0
|
||||
assert job.eml_storage_key == "campaign-artifacts/message.eml"
|
||||
session.add.assert_not_called()
|
||||
Reference in New Issue
Block a user