423 lines
16 KiB
Python
423 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import UTC, datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import Column, String, Table, create_engine
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_policy
|
|
from govoplan_campaign.backend.db.models import (
|
|
Campaign,
|
|
CampaignJob,
|
|
CampaignSchedule,
|
|
CampaignShare,
|
|
CampaignVersion,
|
|
)
|
|
from govoplan_campaign.backend.routes.campaigns import (
|
|
_campaign_copy_configuration,
|
|
archive_campaign_version,
|
|
copy_campaign,
|
|
delete_draft_campaign,
|
|
)
|
|
from govoplan_campaign.backend.schemas import (
|
|
CampaignCopyRequest,
|
|
CampaignLifecycleMutationRequest,
|
|
)
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
|
|
|
|
class _Principal:
|
|
tenant_id = "tenant-1"
|
|
user = SimpleNamespace(id="user-1")
|
|
|
|
def __init__(self, *scopes: str) -> None:
|
|
self.scopes = frozenset(scopes)
|
|
|
|
def has(self, scope: str) -> bool:
|
|
return scope in self.scopes or "tenant:*" in self.scopes
|
|
|
|
|
|
class CampaignLifecycleTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
access_users = Base.metadata.tables.get("access_users")
|
|
if access_users is None:
|
|
access_users = Table(
|
|
"access_users",
|
|
Base.metadata,
|
|
Column("id", String(36), primary_key=True),
|
|
)
|
|
access_groups = Base.metadata.tables.get("access_groups")
|
|
if access_groups is None:
|
|
access_groups = Table(
|
|
"access_groups",
|
|
Base.metadata,
|
|
Column("id", String(36), primary_key=True),
|
|
)
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
access_users,
|
|
access_groups,
|
|
Campaign.__table__,
|
|
CampaignVersion.__table__,
|
|
CampaignShare.__table__,
|
|
CampaignJob.__table__,
|
|
CampaignSchedule.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
],
|
|
)
|
|
self.SessionLocal = sessionmaker(
|
|
bind=self.engine,
|
|
class_=Session,
|
|
expire_on_commit=False,
|
|
)
|
|
with self.SessionLocal() as session:
|
|
user_values = {"id": "user-1"}
|
|
if "tenant_id" in access_users.c:
|
|
user_values.update(
|
|
tenant_id="tenant-1",
|
|
account_id="account-1",
|
|
email="user-1@example.test",
|
|
)
|
|
session.execute(access_users.insert().values(**user_values))
|
|
campaign = Campaign(
|
|
id="campaign-1",
|
|
tenant_id="tenant-1",
|
|
created_by_user_id="user-1",
|
|
owner_user_id="user-1",
|
|
external_id="campaign-1",
|
|
name="Campaign",
|
|
status="draft",
|
|
current_version_id="version-2",
|
|
)
|
|
historical = CampaignVersion(
|
|
id="version-1",
|
|
campaign_id=campaign.id,
|
|
version_number=1,
|
|
workflow_state="completed",
|
|
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
|
|
)
|
|
current = CampaignVersion(
|
|
id="version-2",
|
|
campaign_id=campaign.id,
|
|
version_number=2,
|
|
workflow_state="editing",
|
|
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
|
|
)
|
|
session.add_all((campaign, historical, current))
|
|
session.commit()
|
|
self.principal = _Principal(
|
|
"campaigns:campaign:read",
|
|
"campaigns:campaign:copy",
|
|
"campaigns:campaign:archive",
|
|
"campaigns:campaign:delete",
|
|
"campaigns:recipient:read",
|
|
)
|
|
self.addCleanup(self.engine.dispose)
|
|
|
|
def _policy(self, session: Session, version_id: str | None = None):
|
|
campaign = session.get(Campaign, "campaign-1")
|
|
assert campaign is not None
|
|
return campaign_lifecycle_policy(
|
|
session,
|
|
campaign=campaign,
|
|
principal=self.principal,
|
|
version_id=version_id,
|
|
)
|
|
|
|
def test_policy_explains_retained_evidence_and_changes_token(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
initial = self._policy(session, "version-1")
|
|
self.assertTrue(initial["actions"]["delete_campaign"]["allowed"])
|
|
self.assertTrue(initial["actions"]["archive_version"]["allowed"])
|
|
|
|
current = session.get(CampaignVersion, "version-2")
|
|
assert current is not None
|
|
current.published_at = datetime.now(UTC)
|
|
session.commit()
|
|
changed = self._policy(session, "version-1")
|
|
|
|
self.assertNotEqual(initial["state_token"], changed["state_token"])
|
|
self.assertFalse(changed["actions"]["delete_campaign"]["allowed"])
|
|
self.assertIn("Audit-relevant", changed["actions"]["delete_campaign"]["reason"])
|
|
|
|
def test_active_delivery_blocks_campaign_archival(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
session.add(
|
|
CampaignJob(
|
|
id="job-1",
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
campaign_version_id="version-2",
|
|
entry_index=0,
|
|
queue_status="sending",
|
|
send_status="sending",
|
|
)
|
|
)
|
|
session.commit()
|
|
policy = self._policy(session)
|
|
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
|
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
|
|
|
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
session.add(
|
|
CampaignSchedule(
|
|
id="schedule-1",
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
source_version_id="version-2",
|
|
created_by_user_id="user-1",
|
|
name="Recurring draft",
|
|
recurrence_kind="daily",
|
|
interval_count=1,
|
|
timezone="UTC",
|
|
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
|
|
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
|
|
max_occurrences=2,
|
|
copy_options={},
|
|
source_snapshot={"schema": "test"},
|
|
source_snapshot_hash="a" * 64,
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
active_policy = self._policy(session)
|
|
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
|
|
self.assertIn(
|
|
"Pause active",
|
|
active_policy["actions"]["archive_campaign"]["reason"],
|
|
)
|
|
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
|
|
self.assertIn(
|
|
"schedule evidence",
|
|
active_policy["actions"]["delete_campaign"]["reason"],
|
|
)
|
|
|
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
|
assert schedule is not None
|
|
schedule.active = False
|
|
schedule.resource_revision += 1
|
|
session.commit()
|
|
paused_policy = self._policy(session)
|
|
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
|
|
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
|
|
|
|
def test_stale_delete_token_is_rejected(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
policy = self._policy(session)
|
|
campaign = session.get(Campaign, "campaign-1")
|
|
assert campaign is not None
|
|
campaign.name = "Changed elsewhere"
|
|
session.commit()
|
|
|
|
with self.assertRaises(HTTPException) as raised:
|
|
delete_draft_campaign(
|
|
"campaign-1",
|
|
CampaignLifecycleMutationRequest(
|
|
expected_state_token=policy["state_token"],
|
|
),
|
|
session=session,
|
|
principal=self.principal,
|
|
)
|
|
self.assertEqual(raised.exception.status_code, 409)
|
|
self.assertIn("state changed", str(raised.exception.detail))
|
|
|
|
def test_historical_archival_preserves_version_state_and_content(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
policy = self._policy(session, "version-1")
|
|
|
|
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
|
active_session.commit()
|
|
|
|
with patch(
|
|
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
|
|
side_effect=commit_audit,
|
|
):
|
|
response = archive_campaign_version(
|
|
"campaign-1",
|
|
"version-1",
|
|
CampaignLifecycleMutationRequest(
|
|
expected_state_token=policy["state_token"],
|
|
),
|
|
session=session,
|
|
principal=self.principal,
|
|
)
|
|
|
|
self.assertIsNotNone(response.archived_at)
|
|
self.assertEqual(response.archived_by_user_id, "user-1")
|
|
version = session.get(CampaignVersion, "version-1")
|
|
assert version is not None
|
|
self.assertEqual(version.workflow_state, "completed")
|
|
self.assertEqual(version.raw_json["campaign"]["name"], "Campaign")
|
|
|
|
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
|
with self.SessionLocal() as session:
|
|
source_campaign = session.get(Campaign, "campaign-1")
|
|
assert source_campaign is not None
|
|
source_campaign.settings = {"retention": "source-policy"}
|
|
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
|
|
session.add_all(
|
|
(
|
|
CampaignShare(
|
|
id="share-1",
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
target_type="user",
|
|
target_id="user-1",
|
|
permission="read",
|
|
),
|
|
CampaignJob(
|
|
id="job-1",
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
campaign_version_id="version-2",
|
|
entry_index=0,
|
|
queue_status="cancelled",
|
|
send_status="cancelled",
|
|
),
|
|
)
|
|
)
|
|
session.commit()
|
|
policy = self._policy(session, "version-2")
|
|
captured: dict[str, object] = {}
|
|
|
|
def create_copy(active_session: Session, **kwargs):
|
|
raw_json = kwargs["raw_json"]
|
|
captured["raw_json"] = raw_json
|
|
destination = Campaign(
|
|
id="campaign-copy",
|
|
tenant_id="tenant-1",
|
|
created_by_user_id="user-1",
|
|
owner_user_id="user-1",
|
|
external_id=raw_json["campaign"]["id"],
|
|
name=raw_json["campaign"]["name"],
|
|
status="draft",
|
|
current_version_id="version-copy",
|
|
)
|
|
version = CampaignVersion(
|
|
id="version-copy",
|
|
campaign_id=destination.id,
|
|
version_number=1,
|
|
raw_json=raw_json,
|
|
)
|
|
active_session.add_all((destination, version))
|
|
active_session.flush()
|
|
return destination, version
|
|
|
|
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
|
active_session.commit()
|
|
|
|
with (
|
|
patch(
|
|
"govoplan_campaign.backend.routes.campaigns.create_campaign_version_from_json",
|
|
side_effect=create_copy,
|
|
),
|
|
patch(
|
|
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
|
|
side_effect=commit_audit,
|
|
),
|
|
):
|
|
response = copy_campaign(
|
|
"campaign-1",
|
|
CampaignCopyRequest(
|
|
source_version_id="version-2",
|
|
expected_state_token=policy["state_token"],
|
|
include_policies=False,
|
|
include_mail_profile=True,
|
|
),
|
|
session=session,
|
|
principal=self.principal,
|
|
)
|
|
|
|
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
|
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
|
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
|
copied_campaign = session.get(Campaign, "campaign-copy")
|
|
assert copied_campaign is not None
|
|
self.assertEqual({}, copied_campaign.settings)
|
|
self.assertEqual(
|
|
{"profile_id": "mail-profile-1"},
|
|
copied_campaign.mail_profile_policy,
|
|
)
|
|
self.assertEqual(
|
|
session.query(CampaignJob)
|
|
.filter(CampaignJob.campaign_id == "campaign-copy")
|
|
.count(),
|
|
0,
|
|
)
|
|
self.assertEqual(
|
|
session.query(CampaignShare)
|
|
.filter(CampaignShare.campaign_id == "campaign-copy")
|
|
.count(),
|
|
0,
|
|
)
|
|
|
|
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
|
|
source = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "campaign-1", "name": "Campaign"},
|
|
"fields": [{"name": "case_id"}],
|
|
"global_values": {"sender": "Office"},
|
|
"recipients": {"to": [{"email": "team@example.test"}]},
|
|
"entries": {
|
|
"inline": [
|
|
{
|
|
"id": "one",
|
|
"to": [{"email": "person@example.test"}],
|
|
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
|
|
}
|
|
]
|
|
},
|
|
"template": {"subject": "Hello", "text": "Body"},
|
|
"attachments": {
|
|
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
|
|
},
|
|
"validation_policy": {"missing_required_attachment": "warn"},
|
|
"server": {"mail_profile_id": "profile-1"},
|
|
"delivery": {"rate_limit": {"messages_per_minute": 20}},
|
|
}
|
|
|
|
copied = _campaign_copy_configuration(
|
|
source,
|
|
CampaignCopyRequest(
|
|
source_version_id="version-2",
|
|
expected_state_token="a" * 64,
|
|
include_recipients=True,
|
|
include_files=False,
|
|
include_policies=False,
|
|
include_mail_profile=False,
|
|
),
|
|
)
|
|
|
|
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
|
|
self.assertEqual({}, copied["attachments"])
|
|
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
|
|
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
|
|
self.assertEqual({}, copied["validation_policy"])
|
|
self.assertEqual({}, copied["server"])
|
|
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
|
|
|
|
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
|
|
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
|
|
with self.SessionLocal() as session:
|
|
campaign = session.get(Campaign, "campaign-1")
|
|
assert campaign is not None
|
|
policy = campaign_lifecycle_policy(
|
|
session,
|
|
campaign=campaign,
|
|
principal=principal,
|
|
version_id="version-2",
|
|
)
|
|
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|