2822 lines
129 KiB
Python
2822 lines
129 KiB
Python
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
import zipfile
|
|
from datetime import datetime, timezone
|
|
from email import policy
|
|
from email.parser import BytesParser
|
|
from pathlib import Path
|
|
|
|
import pyzipper
|
|
|
|
# Settings are instantiated while importing the application. Configure an
|
|
# isolated test database and local storage before importing app modules.
|
|
_TEST_ROOT = Path(tempfile.mkdtemp(prefix="multi-seal-mail-tests-"))
|
|
os.environ["APP_ENV"] = "test"
|
|
os.environ["DATABASE_URL"] = f"sqlite:///{_TEST_ROOT / 'test.db'}"
|
|
os.environ["FILE_STORAGE_BACKEND"] = "local"
|
|
os.environ["FILE_STORAGE_LOCAL_ROOT"] = str(_TEST_ROOT / "files")
|
|
os.environ["MOCK_MAILBOX_DIR"] = str(_TEST_ROOT / "mock-mailbox")
|
|
os.environ["DEV_BOOTSTRAP_ENABLED"] = "false"
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
|
from govoplan_core.db.session import configure_database
|
|
|
|
_database = configure_database(os.environ["DATABASE_URL"])
|
|
engine = _database.engine
|
|
SessionLocal = _database.SessionLocal
|
|
|
|
from govoplan_core.server.app import app
|
|
from govoplan_core.security.permissions import SYSTEM_SCOPES
|
|
|
|
|
|
class ApiSmokeTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
cls.client = TestClient(app)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.client.close()
|
|
engine.dispose()
|
|
shutil.rmtree(_TEST_ROOT, ignore_errors=True)
|
|
|
|
def setUp(self) -> None:
|
|
self.client.cookies.clear()
|
|
Base.metadata.drop_all(bind=engine)
|
|
Base.metadata.create_all(bind=engine)
|
|
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
|
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
|
with SessionLocal() as session:
|
|
bootstrap_dev_data(
|
|
session,
|
|
api_key_secret="test-api-key",
|
|
user_password="test-admin",
|
|
)
|
|
|
|
def _login(self) -> tuple[dict[str, str], dict[str, object]]:
|
|
response = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "admin@example.local", "password": "test-admin"},
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
payload = response.json()
|
|
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
|
|
|
def _create_built_delivery_campaign(
|
|
self,
|
|
headers: dict[str, str],
|
|
*,
|
|
external_id: str,
|
|
recipient_count: int = 1,
|
|
) -> tuple[str, str]:
|
|
entries = [
|
|
{
|
|
"id": f"recipient-{index + 1}",
|
|
"to": [{"email": f"recipient-{index + 1}@example.org", "type": "to"}],
|
|
"fields": {"first_name": f"Recipient {index + 1}"},
|
|
}
|
|
for index in range(recipient_count)
|
|
]
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": external_id, "name": external_id, "mode": "test"},
|
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {
|
|
"smtp": {
|
|
"host": "mock.smtp",
|
|
"port": 2525,
|
|
"username": "sender@example.org",
|
|
"password": "test-secret",
|
|
"security": "starttls",
|
|
}
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"allow_individual_to": True,
|
|
},
|
|
"template": {
|
|
"subject": "Hello ${local::first_name}",
|
|
"text": "Hello ${local::first_name}.",
|
|
},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {"inline": entries},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {
|
|
"rate_limit": {"messages_per_minute": 60},
|
|
"retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]},
|
|
"imap_append_sent": {"enabled": False},
|
|
},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": False},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/build",
|
|
headers=headers,
|
|
json={"write_eml": True},
|
|
)
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["built_count"], recipient_count, built.text)
|
|
return campaign_id, version_id
|
|
|
|
def _create_role(self, headers: dict[str, str], *, slug: str, permissions: list[str]) -> dict[str, object]:
|
|
response = self.client.post(
|
|
"/api/v1/admin/roles",
|
|
headers=headers,
|
|
json={"slug": slug, "name": slug.replace("-", " ").title(), "description": "Test role", "permissions": permissions},
|
|
)
|
|
self.assertEqual(response.status_code, 201, response.text)
|
|
return response.json()
|
|
|
|
def _create_user(
|
|
self,
|
|
headers: dict[str, str],
|
|
*,
|
|
email: str,
|
|
password: str,
|
|
role_ids: list[str],
|
|
group_ids: list[str] | None = None,
|
|
) -> dict[str, object]:
|
|
response = self.client.post(
|
|
"/api/v1/admin/users",
|
|
headers=headers,
|
|
json={
|
|
"email": email,
|
|
"display_name": email.split("@", 1)[0].replace("-", " ").title(),
|
|
"password": password,
|
|
"password_reset_required": False,
|
|
"is_active": True,
|
|
"group_ids": group_ids or [],
|
|
"role_ids": role_ids,
|
|
},
|
|
)
|
|
self.assertEqual(response.status_code, 201, response.text)
|
|
return response.json()["user"]
|
|
|
|
def _login_as(self, *, email: str, password: str, tenant_slug: str = "default") -> tuple[dict[str, str], dict[str, object]]:
|
|
response = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": email, "password": password, "tenant_slug": tenant_slug},
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
payload = response.json()
|
|
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
|
|
|
|
|
|
def test_cookie_session_requires_csrf_for_mutations(self) -> None:
|
|
response = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "admin@example.local", "password": "test-admin"},
|
|
)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
csrf = response.cookies.get("msm_csrf")
|
|
self.assertTrue(csrf)
|
|
|
|
me = self.client.get("/api/v1/auth/me")
|
|
self.assertEqual(me.status_code, 200, me.text)
|
|
|
|
blocked = self.client.patch("/api/v1/auth/profile", json={"display_name": "Blocked"})
|
|
self.assertEqual(blocked.status_code, 403, blocked.text)
|
|
|
|
allowed = self.client.patch(
|
|
"/api/v1/auth/profile",
|
|
headers={"X-CSRF-Token": csrf or ""},
|
|
json={"display_name": "Cookie Admin"},
|
|
)
|
|
self.assertEqual(allowed.status_code, 200, allowed.text)
|
|
self.assertEqual(allowed.json()["user"]["display_name"], "Cookie Admin")
|
|
|
|
def test_encrypted_mail_profile_can_back_campaign_without_exposing_secrets(self) -> None:
|
|
headers, _ = self._login()
|
|
created_profile = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Primary sender",
|
|
"smtp": {
|
|
"host": "mock.smtp",
|
|
"port": 2525,
|
|
"username": "sender@example.org",
|
|
"password": "smtp-secret",
|
|
"security": "starttls",
|
|
},
|
|
"imap": {
|
|
"enabled": True,
|
|
"host": "mock.imap",
|
|
"port": 993,
|
|
"username": "sender@example.org",
|
|
"password": "imap-secret",
|
|
"security": "tls",
|
|
"sent_folder": "Sent",
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(created_profile.status_code, 201, created_profile.text)
|
|
profile_payload = created_profile.json()
|
|
self.assertNotIn("password", profile_payload["smtp"])
|
|
self.assertNotIn("password", profile_payload["imap"])
|
|
profile_id = profile_payload["id"]
|
|
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "profile-backed", "name": "Profile backed", "mode": "test"},
|
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {"mail_profile_id": profile_id},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
|
},
|
|
"template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {
|
|
"rate_limit": {"messages_per_minute": 60},
|
|
"retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]},
|
|
"imap_append_sent": {"enabled": False},
|
|
},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created_campaign = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created_campaign.status_code, 200, created_campaign.text)
|
|
version_id = created_campaign.json()["version"]["id"]
|
|
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignVersion
|
|
from govoplan_mail.backend.db.models import MailServerProfile
|
|
|
|
with SessionLocal() as session:
|
|
profile = session.get(MailServerProfile, profile_id)
|
|
self.assertIsNotNone(profile)
|
|
assert profile is not None
|
|
self.assertNotEqual(profile.smtp_password_encrypted, "smtp-secret")
|
|
self.assertNotEqual(profile.imap_password_encrypted, "imap-secret")
|
|
self.assertNotIn("password", profile.smtp_config)
|
|
self.assertNotIn("password", profile.imap_config or {})
|
|
|
|
version = session.get(CampaignVersion, version_id)
|
|
self.assertIsNotNone(version)
|
|
assert version is not None
|
|
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
|
snapshot = version.execution_snapshot or {}
|
|
self.assertIsNone(snapshot.get("smtp", {}).get("password"))
|
|
self.assertEqual(snapshot.get("smtp", {}).get("host"), "mock.smtp")
|
|
|
|
def test_mail_profile_can_inherit_server_without_credentials(self) -> None:
|
|
headers, _ = self._login()
|
|
created_profile = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Shared host local credentials",
|
|
"smtp": {
|
|
"host": "mock.smtp",
|
|
"port": 2525,
|
|
"username": "profile-smtp@example.org",
|
|
"password": "profile-smtp-secret",
|
|
"security": "starttls",
|
|
},
|
|
"imap": {
|
|
"enabled": True,
|
|
"host": "mock.imap",
|
|
"port": 993,
|
|
"username": "profile-imap@example.org",
|
|
"password": "profile-imap-secret",
|
|
"security": "tls",
|
|
"sent_folder": "Sent",
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(created_profile.status_code, 201, created_profile.text)
|
|
profile_id = created_profile.json()["id"]
|
|
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "profile-local-creds", "name": "Profile local creds", "mode": "test"},
|
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {
|
|
"mail_profile_id": profile_id,
|
|
"inherit_smtp_credentials": False,
|
|
"inherit_imap_credentials": False,
|
|
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
|
"imap": {"username": "campaign-imap@example.org", "password": "campaign-imap-secret"},
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
|
},
|
|
"template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {
|
|
"rate_limit": {"messages_per_minute": 60},
|
|
"retry": {"max_attempts": 3, "backoff_seconds": [1, 5, 30]},
|
|
"imap_append_sent": {"enabled": False},
|
|
},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created_campaign = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created_campaign.status_code, 200, created_campaign.text)
|
|
version_id = created_campaign.json()["version"]["id"]
|
|
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": False})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignVersion
|
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, runtime_imap_config, runtime_smtp_config
|
|
|
|
with SessionLocal() as session:
|
|
version = session.get(CampaignVersion, version_id)
|
|
self.assertIsNotNone(version)
|
|
assert version is not None
|
|
snapshot_payload = version.execution_snapshot or {}
|
|
self.assertEqual(snapshot_payload.get("smtp", {}).get("host"), "mock.smtp")
|
|
self.assertEqual(snapshot_payload.get("smtp", {}).get("username"), "campaign-smtp@example.org")
|
|
self.assertIsNone(snapshot_payload.get("smtp", {}).get("password"))
|
|
self.assertEqual(snapshot_payload.get("imap", {}).get("host"), "mock.imap")
|
|
self.assertEqual(snapshot_payload.get("imap", {}).get("username"), "campaign-imap@example.org")
|
|
self.assertIsNone(snapshot_payload.get("imap", {}).get("password"))
|
|
|
|
snapshot = ExecutionSnapshot.model_validate(snapshot_payload)
|
|
smtp_runtime = runtime_smtp_config(session, version, snapshot)
|
|
imap_runtime = runtime_imap_config(session, version, snapshot)
|
|
self.assertEqual(smtp_runtime.host, "mock.smtp")
|
|
self.assertEqual(smtp_runtime.username, "campaign-smtp@example.org")
|
|
self.assertEqual(smtp_runtime.password, "campaign-smtp-secret")
|
|
self.assertIsNotNone(imap_runtime)
|
|
assert imap_runtime is not None
|
|
self.assertEqual(imap_runtime.host, "mock.imap")
|
|
self.assertEqual(imap_runtime.username, "campaign-imap@example.org")
|
|
self.assertEqual(imap_runtime.password, "campaign-imap-secret")
|
|
|
|
def test_health_schema_and_dev_mailbox_gates(self) -> None:
|
|
public_health = self.client.get("/health")
|
|
self.assertEqual(public_health.status_code, 200, public_health.text)
|
|
self.assertEqual(public_health.json(), {"status": "ok"})
|
|
|
|
unauthenticated_details = self.client.get("/health/details")
|
|
self.assertEqual(unauthenticated_details.status_code, 401, unauthenticated_details.text)
|
|
|
|
headers, _ = self._login()
|
|
details = self.client.get("/health/details", headers=headers)
|
|
self.assertEqual(details.status_code, 200, details.text)
|
|
self.assertIn("storage", details.json())
|
|
|
|
schema = self.client.get("/api/v1/schemas/campaign", headers=headers)
|
|
self.assertEqual(schema.status_code, 200, schema.text)
|
|
self.assertEqual(schema.json()["title"], "MultiMailer Campaign")
|
|
|
|
dev_mailbox = self.client.get("/api/v1/dev/mailbox/messages", headers=headers)
|
|
self.assertEqual(dev_mailbox.status_code, 404, dev_mailbox.text)
|
|
|
|
def test_managed_file_runtime_flow(self) -> None:
|
|
headers, login = self._login()
|
|
user_id = login["user"]["id"]
|
|
|
|
spaces = self.client.get("/api/v1/files/spaces", headers=headers)
|
|
self.assertEqual(spaces.status_code, 200, spaces.text)
|
|
self.assertEqual(spaces.json()["spaces"][0]["owner_id"], user_id)
|
|
|
|
folder = self.client.post(
|
|
"/api/v1/files/folders",
|
|
headers=headers,
|
|
json={"owner_type": "user", "owner_id": user_id, "path": "inbox"},
|
|
)
|
|
self.assertEqual(folder.status_code, 200, folder.text)
|
|
|
|
first = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=headers,
|
|
data={"owner_type": "user", "owner_id": user_id, "path": "inbox"},
|
|
files=[("files", ("report.txt", b"first report", ""))],
|
|
)
|
|
self.assertEqual(first.status_code, 200, first.text)
|
|
first_file = first.json()["files"][0]
|
|
self.assertEqual(first_file["display_path"], "inbox/report.txt")
|
|
self.assertEqual(first_file["content_type"], "text/plain")
|
|
|
|
# Exercises request-model conversion to FileConflictResolution and the
|
|
# explicit rename path rather than silently overwriting an asset.
|
|
second = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=headers,
|
|
data={
|
|
"owner_type": "user",
|
|
"owner_id": user_id,
|
|
"path": "inbox",
|
|
"conflict_resolutions_json": json.dumps(
|
|
[
|
|
{
|
|
"target_path": "inbox/report.txt",
|
|
"action": "rename",
|
|
"new_path": "inbox/report-copy.txt",
|
|
}
|
|
]
|
|
),
|
|
},
|
|
files=[("files", ("report.txt", b"second report", "text/plain"))],
|
|
)
|
|
self.assertEqual(second.status_code, 200, second.text)
|
|
second_file = second.json()["files"][0]
|
|
self.assertEqual(second_file["display_path"], "inbox/report-copy.txt")
|
|
|
|
listing = self.client.get(
|
|
"/api/v1/files",
|
|
headers=headers,
|
|
params={"owner_type": "user", "owner_id": user_id, "path_prefix": "inbox"},
|
|
)
|
|
self.assertEqual(listing.status_code, 200, listing.text)
|
|
self.assertEqual(len(listing.json()["files"]), 2)
|
|
|
|
resolved = self.client.post(
|
|
"/api/v1/files/resolve-patterns",
|
|
headers=headers,
|
|
json={
|
|
"patterns": ["**/*.txt"],
|
|
"owner_type": "user",
|
|
"owner_id": user_id,
|
|
"include_unmatched": True,
|
|
},
|
|
)
|
|
self.assertEqual(resolved.status_code, 200, resolved.text)
|
|
self.assertEqual(len(resolved.json()["patterns"][0]["matches"]), 2)
|
|
self.assertEqual(resolved.json()["unmatched"], [])
|
|
|
|
# Exercises RenamePlanItem construction without mutating persisted paths.
|
|
rename_preview = self.client.post(
|
|
"/api/v1/files/bulk-rename",
|
|
headers=headers,
|
|
json={
|
|
"file_ids": [first_file["id"]],
|
|
"mode": "prefix",
|
|
"prefix": "archived-",
|
|
"dry_run": True,
|
|
},
|
|
)
|
|
self.assertEqual(rename_preview.status_code, 200, rename_preview.text)
|
|
self.assertEqual(rename_preview.json()["items"][0]["new_path"], "inbox/archived-report.txt")
|
|
|
|
invalid_share = self.client.post(
|
|
f"/api/v1/files/{first_file['id']}/shares",
|
|
headers=headers,
|
|
json={"target_type": "user", "target_id": "missing-user", "permission": "read"},
|
|
)
|
|
self.assertEqual(invalid_share.status_code, 400, invalid_share.text)
|
|
|
|
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
|
|
self.assertEqual(download.status_code, 200, download.text)
|
|
self.assertIn("filename*=UTF-8''report.txt", download.headers.get("content-disposition", ""))
|
|
self.assertEqual(download.content, b"first report")
|
|
|
|
archive = self.client.post(
|
|
"/api/v1/files/archive.zip",
|
|
headers=headers,
|
|
json={"file_ids": [first_file["id"], second_file["id"]], "filename": "reports.zip"},
|
|
)
|
|
self.assertEqual(archive.status_code, 200, archive.text)
|
|
with zipfile.ZipFile(io.BytesIO(archive.content)) as bundle:
|
|
self.assertEqual(sorted(bundle.namelist()), ["inbox/report-copy.txt", "inbox/report.txt"])
|
|
self.assertEqual(bundle.read("inbox/report.txt"), b"first report")
|
|
|
|
# A historical soft-deleted folder row can coexist with the active row.
|
|
# Moving/copying through that hierarchy must choose the active row rather
|
|
# than raising MultipleResultsFound.
|
|
target_folder = self.client.post(
|
|
"/api/v1/files/folders",
|
|
headers=headers,
|
|
json={"owner_type": "user", "owner_id": user_id, "path": "archive"},
|
|
)
|
|
self.assertEqual(target_folder.status_code, 200, target_folder.text)
|
|
from govoplan_files.backend.db.models import FileFolder
|
|
from govoplan_files.backend.storage.common import utcnow
|
|
with SessionLocal() as session:
|
|
session.add(FileFolder(
|
|
tenant_id=next(iter({folder.tenant_id for folder in session.query(FileFolder).filter(FileFolder.owner_user_id == user_id).all()})),
|
|
owner_type="user",
|
|
owner_user_id=user_id,
|
|
path="archive",
|
|
created_by_user_id=user_id,
|
|
deleted_at=utcnow(),
|
|
metadata_={},
|
|
))
|
|
session.commit()
|
|
transferred = self.client.post(
|
|
"/api/v1/files/transfer",
|
|
headers=headers,
|
|
json={
|
|
"operation": "copy",
|
|
"file_ids": [first_file["id"]],
|
|
"folder_paths": [],
|
|
"source_owner_type": "user",
|
|
"source_owner_id": user_id,
|
|
"target_owner_type": "user",
|
|
"target_owner_id": user_id,
|
|
"target_folder": "archive/2026",
|
|
"conflict_strategy": "reject",
|
|
},
|
|
)
|
|
self.assertEqual(transferred.status_code, 200, transferred.text)
|
|
self.assertEqual(transferred.json()["files"], 1)
|
|
|
|
def test_group_read_admin_does_not_grant_file_space_admin(self) -> None:
|
|
owner_headers, _ = self._login()
|
|
group = self.client.post(
|
|
"/api/v1/admin/groups",
|
|
headers=owner_headers,
|
|
json={
|
|
"slug": "private-files",
|
|
"name": "Private files",
|
|
"description": "Contains files that are not shared with group readers.",
|
|
"member_ids": [],
|
|
"role_ids": [],
|
|
},
|
|
)
|
|
self.assertEqual(group.status_code, 201, group.text)
|
|
group_id = group.json()["id"]
|
|
|
|
uploaded = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=owner_headers,
|
|
data={"owner_type": "group", "owner_id": group_id, "path": ""},
|
|
files=[("files", ("private.txt", b"private", "text/plain"))],
|
|
)
|
|
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
|
file_id = uploaded.json()["files"][0]["id"]
|
|
|
|
role = self._create_role(
|
|
owner_headers,
|
|
slug="group-directory-reader",
|
|
permissions=["files:read", "admin:groups:read"],
|
|
)
|
|
self._create_user(
|
|
owner_headers,
|
|
email="group-reader@example.local",
|
|
password="group-reader-password",
|
|
role_ids=[str(role["id"])],
|
|
)
|
|
reader_headers, _ = self._login_as(email="group-reader@example.local", password="group-reader-password")
|
|
|
|
groups = self.client.get("/api/v1/admin/groups", headers=reader_headers)
|
|
self.assertEqual(groups.status_code, 200, groups.text)
|
|
|
|
spaces = self.client.get("/api/v1/files/spaces", headers=reader_headers)
|
|
self.assertEqual(spaces.status_code, 200, spaces.text)
|
|
self.assertNotIn(group_id, [space["owner_id"] for space in spaces.json()["spaces"]])
|
|
|
|
group_listing = self.client.get(
|
|
"/api/v1/files",
|
|
headers=reader_headers,
|
|
params={"owner_type": "group", "owner_id": group_id},
|
|
)
|
|
self.assertEqual(group_listing.status_code, 403, group_listing.text)
|
|
self.assertEqual(self.client.get(f"/api/v1/files/{file_id}", headers=reader_headers).status_code, 404)
|
|
|
|
def test_temporary_and_permanent_user_lock_lifecycle(self) -> None:
|
|
headers, _ = self._login()
|
|
created = self.client.post(
|
|
"/api/v1/campaigns/new",
|
|
headers=headers,
|
|
json={"external_id": "lock-lifecycle", "name": "Lock lifecycle"},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
|
|
temporary = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(temporary.status_code, 200, temporary.text)
|
|
self.assertEqual(temporary.json()["user_lock_state"], "temporary")
|
|
self.assertTrue(temporary.json()["user_locked_at"])
|
|
|
|
blocked_update = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=headers,
|
|
json={"current_step": "fields"},
|
|
)
|
|
self.assertEqual(blocked_update.status_code, 409, blocked_update.text)
|
|
|
|
unlocked = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(unlocked.status_code, 200, unlocked.text)
|
|
self.assertIsNone(unlocked.json()["user_lock_state"])
|
|
|
|
updated = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=headers,
|
|
json={"current_step": "fields"},
|
|
)
|
|
self.assertEqual(updated.status_code, 200, updated.text)
|
|
self.assertEqual(updated.json()["current_step"], "fields")
|
|
|
|
relocked = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-temporarily",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(relocked.status_code, 200, relocked.text)
|
|
|
|
permanent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/lock-permanently",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(permanent.status_code, 200, permanent.text)
|
|
self.assertEqual(permanent.json()["user_lock_state"], "permanent")
|
|
self.assertTrue(permanent.json()["published_at"])
|
|
|
|
refused_unlock = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(refused_unlock.status_code, 409, refused_unlock.text)
|
|
|
|
|
|
def test_only_one_campaign_version_is_writable(self) -> None:
|
|
headers, _ = self._login()
|
|
created = self.client.post(
|
|
"/api/v1/campaigns/new",
|
|
headers=headers,
|
|
json={"external_id": "single-working-version", "name": "Single working version"},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
first_version_id = created.json()["version"]["id"]
|
|
|
|
parallel = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(parallel.status_code, 409, parallel.text)
|
|
self.assertIn("active working version", parallel.text)
|
|
|
|
permanent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/lock-permanently",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(permanent.status_code, 200, permanent.text)
|
|
|
|
copied = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(copied.status_code, 200, copied.text)
|
|
second_version_id = copied.json()["version"]["id"]
|
|
self.assertNotEqual(second_version_id, first_version_id)
|
|
self.assertEqual(copied.json()["campaign"]["current_version_id"], second_version_id)
|
|
|
|
historical_update = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}",
|
|
headers=headers,
|
|
json={"current_step": "fields"},
|
|
)
|
|
self.assertEqual(historical_update.status_code, 409, historical_update.text)
|
|
self.assertIn("Historical campaign versions are read-only", historical_update.text)
|
|
|
|
second_update = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
|
|
headers=headers,
|
|
json={"current_step": "fields"},
|
|
)
|
|
self.assertEqual(second_update.status_code, 200, second_update.text)
|
|
|
|
second_parallel = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(second_parallel.status_code, 409, second_parallel.text)
|
|
|
|
second_permanent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/lock-permanently",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(second_permanent.status_code, 200, second_permanent.text)
|
|
|
|
historical_branch = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(historical_branch.status_code, 409, historical_branch.text)
|
|
self.assertIn("cannot become a new branch", historical_branch.text)
|
|
|
|
third = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(third.status_code, 200, third.text)
|
|
self.assertEqual(third.json()["version"]["version_number"], 3)
|
|
|
|
def test_campaign_create_validate_build_and_mock_send(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "api-smoke", "name": "API smoke campaign", "mode": "test"},
|
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {
|
|
"smtp": {
|
|
"host": "smtp.example.invalid",
|
|
"port": 587,
|
|
"username": "sender@example.org",
|
|
"password": "test-secret",
|
|
"security": "starttls",
|
|
},
|
|
"imap": {
|
|
"enabled": True,
|
|
"host": "imap.example.invalid",
|
|
"port": 993,
|
|
"username": "sender@example.org",
|
|
"password": "test-secret",
|
|
"security": "tls",
|
|
},
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"allow_individual_to": True,
|
|
},
|
|
"template": {
|
|
"subject": "Hello ${local::first_name}",
|
|
"text": "Hello ${local::first_name}, this is a smoke test.",
|
|
},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {
|
|
"inline": [
|
|
{
|
|
"id": "recipient-1",
|
|
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
|
|
"fields": {"first_name": "Ada"},
|
|
}
|
|
]
|
|
},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": True, "folder": "Sent"}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
|
|
created = self.client.post(
|
|
"/api/v1/campaigns",
|
|
headers=headers,
|
|
json={"config": campaign_json},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
created_payload = created.json()
|
|
campaign_id = created_payload["campaign"]["id"]
|
|
version_id = created_payload["version"]["id"]
|
|
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": False},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"])
|
|
|
|
built = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/build",
|
|
headers=headers,
|
|
json={"write_eml": False},
|
|
)
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["built_count"], 1)
|
|
|
|
mocked = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/mock-send",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"send": True,
|
|
"append_sent": True,
|
|
"clear_mailbox": True,
|
|
"check_files": False,
|
|
},
|
|
)
|
|
self.assertEqual(mocked.status_code, 200, mocked.text)
|
|
result = mocked.json()["result"]
|
|
self.assertTrue(result["validation"]["ok"])
|
|
self.assertEqual(result["build"]["built_count"], 1)
|
|
self.assertEqual(result["send"]["sent_count"], 1)
|
|
self.assertEqual(result["send"]["imap_appended_count"], 1)
|
|
self.assertEqual(result["send"]["imap_failed_count"], 0)
|
|
|
|
def test_managed_attachment_patterns_preview_build_and_mock_send(self) -> None:
|
|
headers, login = self._login()
|
|
user_id = login["user"]["id"]
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "managed-attachments", "name": "Managed attachments", "mode": "test"},
|
|
"fields": [{"name": "invoice_number", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {
|
|
"smtp": {
|
|
"host": "smtp.example.invalid",
|
|
"port": 587,
|
|
"username": "sender@example.org",
|
|
"password": "test-secret",
|
|
"security": "starttls",
|
|
}
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"allow_individual_to": True,
|
|
},
|
|
"template": {"subject": "Invoice", "text": "Please see the attached files."},
|
|
"attachments": {
|
|
"base_path": "invoices",
|
|
"base_paths": [
|
|
{
|
|
"id": "personal-invoices",
|
|
"name": "My invoices",
|
|
"source": f"managed:user:{user_id}",
|
|
"path": "invoices",
|
|
"allow_individual": True,
|
|
}
|
|
],
|
|
"global": [],
|
|
"allow_individual": True,
|
|
},
|
|
"entries": {
|
|
"inline": [
|
|
{
|
|
"id": "recipient-1",
|
|
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
|
|
"fields": {"invoice_number": "202605-010001"},
|
|
"attachments": [
|
|
{
|
|
"id": "nested-pattern",
|
|
"label": "Nested workbook",
|
|
"base_path_id": "personal-invoices",
|
|
"base_dir": "invoices",
|
|
"file_filter": "**/{{local:invoice_number}}-report.XLSX",
|
|
"required": True,
|
|
},
|
|
{
|
|
"id": "exact-pattern",
|
|
"label": "Exact workbook",
|
|
"base_path_id": "personal-invoices",
|
|
"base_dir": "invoices",
|
|
"file_filter": "{{local:invoice_number}}-90100010-9601741.XLSX",
|
|
"required": True,
|
|
},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
"validation_policy": {
|
|
"missing_email": "block",
|
|
"template_error": "block",
|
|
"missing_required_attachment": "block",
|
|
},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
|
|
for path, filename, content in [
|
|
("invoices/archive", "202605-010001-report.XLSX", b"nested workbook"),
|
|
("invoices", "202605-010001-90100010-9601741.XLSX", b"exact workbook"),
|
|
]:
|
|
uploaded = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=headers,
|
|
data={
|
|
"owner_type": "user",
|
|
"owner_id": user_id,
|
|
"path": path,
|
|
"campaign_id": campaign_id,
|
|
},
|
|
files=[("files", (filename, content, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))],
|
|
)
|
|
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
|
|
|
preview = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
|
headers=headers,
|
|
json={"include_unmatched": True},
|
|
)
|
|
self.assertEqual(preview.status_code, 200, preview.text)
|
|
preview_payload = preview.json()
|
|
self.assertEqual(len(preview_payload["rules"]), 2)
|
|
self.assertEqual([rule["match_count"] for rule in preview_payload["rules"]], [1, 1])
|
|
self.assertEqual(
|
|
{rule["matches"][0]["filename"] for rule in preview_payload["rules"]},
|
|
{"202605-010001-report.XLSX", "202605-010001-90100010-9601741.XLSX"},
|
|
)
|
|
self.assertEqual(preview_payload["unused_shared_files"], [])
|
|
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": True},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
|
|
built = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/build",
|
|
headers=headers,
|
|
json={"write_eml": False},
|
|
)
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["built_count"], 1)
|
|
self.assertEqual(built.json()["messages"][0]["attachment_count"], 2)
|
|
self.assertTrue(built.json().get("built_at"))
|
|
self.assertTrue(built.json().get("build_token"))
|
|
self.assertEqual(
|
|
sum(len(item["managed_matches"]) for item in built.json()["messages"][0]["attachments"]),
|
|
2,
|
|
)
|
|
resolved_paths = {
|
|
match
|
|
for attachment in built.json()["messages"][0]["attachments"]
|
|
for match in attachment["matches"]
|
|
}
|
|
self.assertEqual(resolved_paths, {
|
|
"invoices/archive/202605-010001-report.XLSX",
|
|
"invoices/202605-010001-90100010-9601741.XLSX",
|
|
})
|
|
self.assertFalse(any("multimailer-managed-build" in value for value in resolved_paths))
|
|
|
|
jobs = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
|
headers=headers,
|
|
params={"version_id": version_id},
|
|
)
|
|
self.assertEqual(jobs.status_code, 200, jobs.text)
|
|
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
|
job_summary = jobs.json()["jobs"][0]
|
|
self.assertEqual(job_summary["campaign_version_id"], version_id)
|
|
self.assertNotIn("resolved_recipients", job_summary)
|
|
detail = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs/{job_summary['id']}",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(detail.status_code, 200, detail.text)
|
|
job = detail.json()["job"]
|
|
self.assertEqual(job["resolved_recipients"]["to"][0]["email"], "recipient@example.org")
|
|
self.assertEqual(
|
|
{
|
|
match
|
|
for attachment in job["attachments"]
|
|
for match in attachment["matches"]
|
|
},
|
|
resolved_paths,
|
|
)
|
|
|
|
review_state = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state",
|
|
headers=headers,
|
|
json={"inspection_complete": True, "reviewed_message_keys": ["recipient-1"]},
|
|
)
|
|
self.assertEqual(review_state.status_code, 200, review_state.text)
|
|
stored_review = review_state.json()["editor_state"]["review_send"]
|
|
self.assertTrue(stored_review["inspection_complete"])
|
|
self.assertEqual(stored_review["reviewed_message_keys"], ["recipient-1"])
|
|
self.assertEqual(stored_review["build_token"], built.json()["build_token"])
|
|
|
|
reloaded_version = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(reloaded_version.status_code, 200, reloaded_version.text)
|
|
self.assertTrue(reloaded_version.json()["editor_state"]["review_send"]["inspection_complete"])
|
|
|
|
mocked = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/mock-send",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"send": True,
|
|
"append_sent": False,
|
|
"clear_mailbox": True,
|
|
"check_files": False,
|
|
},
|
|
)
|
|
self.assertEqual(mocked.status_code, 200, mocked.text)
|
|
result = mocked.json()["result"]
|
|
self.assertTrue(result["validation"]["ok"], mocked.text)
|
|
self.assertEqual(result["send"]["sent_count"], 1)
|
|
self.assertEqual(result["send"]["results"][0]["attachments"][0]["status"], "ok")
|
|
|
|
from govoplan_files.backend.db.models import CampaignAttachmentUse
|
|
|
|
with SessionLocal() as session:
|
|
uses = session.query(CampaignAttachmentUse).filter(CampaignAttachmentUse.campaign_id == campaign_id).all()
|
|
self.assertEqual(len(uses), 2)
|
|
self.assertEqual({use.filename_used for use in uses}, {
|
|
"202605-010001-report.XLSX",
|
|
"202605-010001-90100010-9601741.XLSX",
|
|
})
|
|
|
|
|
|
def test_non_blocking_review_conditions_can_be_accepted_in_bulk(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "bulk-review", "name": "Bulk review", "mode": "test"},
|
|
"fields": [], "global_values": {},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
|
"template": {"subject": "Warning test", "text": "Body"},
|
|
"attachments": {"base_path": ".", "base_paths": [], "global": [{
|
|
"id": "optional-missing", "base_dir": ".", "file_filter": "not-there.pdf",
|
|
"required": False, "missing_behavior": "warn", "zip": {"archive_id": "exclude"}
|
|
}], "zip": {"enabled": False, "archives": []}},
|
|
"entries": {"inline": [{"id": "warning-entry", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_optional_attachment": "warn"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}}, "status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": True})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["warning_count"], 1)
|
|
reviewed = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/review-state",
|
|
headers=headers,
|
|
json={"inspection_complete": True, "reviewed_message_keys": []},
|
|
)
|
|
self.assertEqual(reviewed.status_code, 200, reviewed.text)
|
|
review_state = reviewed.json()["editor_state"]["review_send"]
|
|
self.assertTrue(review_state["inspection_complete"])
|
|
self.assertEqual(review_state["reviewed_message_keys"], ["warning-entry"])
|
|
|
|
def test_inactive_recipients_are_aggregated_but_not_built_or_reviewed(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "inactive-recipients", "name": "Inactive recipients", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {"from": {"email": "sender@example.org", "name": "Sender", "type": "to"}, "allow_individual_to": True},
|
|
"template": {"subject": "Hello", "text": "Active recipient only"},
|
|
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
|
|
"entries": {"inline": [
|
|
{"id": "active", "active": True, "to": [{"email": "active@example.org", "type": "to"}]},
|
|
{"id": "inactive", "active": False, "to": [], "attachments": [{"base_dir": "missing", "file_filter": "missing.pdf", "required": True}]},
|
|
]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": True})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["inactive_count"], 1)
|
|
self.assertEqual(len(built.json()["messages"]), 1)
|
|
self.assertEqual(built.json()["messages"][0]["entry_id"], "active")
|
|
jobs = self.client.get(f"/api/v1/campaigns/{campaign_id}/jobs", headers=headers)
|
|
self.assertEqual(jobs.status_code, 200, jobs.text)
|
|
self.assertEqual(len(jobs.json()["jobs"]), 1)
|
|
report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers)
|
|
self.assertEqual(report.status_code, 200, report.text)
|
|
self.assertEqual(report.json()["cards"]["inactive"], 1)
|
|
self.assertEqual(report.json()["status_counts"]["validation"]["inactive"], 1)
|
|
|
|
|
|
def test_recipient_address_fields_and_merge_modes(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "recipient-address-fields", "name": "Recipient address fields", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {
|
|
"from": [{"email": "global-from@example.org", "type": "to"}],
|
|
"allow_individual_from": True,
|
|
"to": [{"email": "global-to@example.org", "type": "to"}],
|
|
"allow_individual_to": True,
|
|
"cc": [{"email": "global-cc@example.org", "type": "cc"}],
|
|
"allow_individual_cc": True,
|
|
"bcc": [{"email": "global-bcc@example.org", "type": "bcc"}],
|
|
"allow_individual_bcc": True,
|
|
"reply_to": [{"email": "global-reply@example.org", "type": "reply_to"}],
|
|
"allow_individual_reply_to": True,
|
|
},
|
|
"template": {
|
|
"subject": "{{local:from.email}} | {{local:all_to.email}} | {{local:to[2].email}} | {{local:all_cc.email}}",
|
|
"text": "{{local:all_from}} / {{local:all_reply_to.email}}",
|
|
},
|
|
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
|
|
"entries": {"inline": [{
|
|
"id": "merged-entry",
|
|
"from": [{"email": "local-from@example.org", "type": "to"}],
|
|
"merge_from": True,
|
|
"to": [{"email": "local-to@example.org", "type": "to"}],
|
|
"merge_to": True,
|
|
"cc": [{"email": "local-cc@example.org", "type": "cc"}],
|
|
"merge_cc": True,
|
|
"bcc": [{"email": "local-bcc@example.org", "type": "bcc"}],
|
|
"merge_bcc": True,
|
|
"reply_to": [{"email": "local-reply@example.org", "type": "reply_to"}],
|
|
"merge_reply_to": True,
|
|
}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["messages"][0]["subject"], "local-from@example.org | global-to@example.org, local-to@example.org | local-to@example.org | global-cc@example.org, local-cc@example.org")
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignJob
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one()
|
|
self.assertEqual([item["email"] for item in job.resolved_recipients["from_all"]], ["local-from@example.org"])
|
|
self.assertEqual([item["email"] for item in job.resolved_recipients["to"]], ["global-to@example.org", "local-to@example.org"])
|
|
message = BytesParser(policy=policy.default).parsebytes(Path(job.eml_local_path).read_bytes())
|
|
self.assertIsNone(message["Sender"])
|
|
self.assertEqual([address.addr_spec for address in message["From"].addresses], ["local-from@example.org"])
|
|
self.assertEqual([address.addr_spec for address in message["To"].addresses], ["global-to@example.org", "local-to@example.org"])
|
|
self.assertEqual([address.addr_spec for address in message["Cc"].addresses], ["global-cc@example.org", "local-cc@example.org"])
|
|
self.assertEqual([address.addr_spec for address in message["Reply-To"].addresses], ["global-reply@example.org", "local-reply@example.org"])
|
|
self.assertIn("local-from@example.org / global-reply@example.org, local-reply@example.org", message.get_body(preferencelist=("plain",)).get_content())
|
|
|
|
def test_multiple_from_addresses_are_rejected(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "multiple-from", "name": "Multiple From", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {
|
|
"from": [
|
|
{"email": "first@example.org", "type": "to"},
|
|
{"email": "second@example.org", "type": "to"},
|
|
],
|
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
|
},
|
|
"template": {"subject": "Subject", "text": "Body"},
|
|
"attachments": {"base_path": ".", "base_paths": [], "global": [], "zip": {"enabled": False, "archives": []}},
|
|
"entries": {"inline": [{"id": "recipient-1"}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 422, created.text)
|
|
|
|
def test_duplicate_zip_archive_filenames_block_validation(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "duplicate-zip-names", "name": "Duplicate ZIP names", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
|
"template": {"subject": "ZIP validation", "text": "Body"},
|
|
"attachments": {
|
|
"base_path": ".",
|
|
"base_paths": [],
|
|
"global": [],
|
|
"zip": {
|
|
"enabled": True,
|
|
"archives": [
|
|
{"id": "first", "name": "Recipient-Bundle", "standard": True},
|
|
{"id": "second", "name": "recipient-bundle.zip", "standard": False},
|
|
],
|
|
},
|
|
},
|
|
"entries": {"inline": [{"id": "recipient", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": False},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertFalse(validated.json()["ok"], validated.text)
|
|
duplicate_issues = [
|
|
issue for issue in validated.json()["issues"]
|
|
if issue["code"] == "zip_archive_name_duplicate"
|
|
]
|
|
self.assertEqual(len(duplicate_issues), 1, validated.text)
|
|
self.assertEqual(duplicate_issues[0]["severity"], "error")
|
|
|
|
def test_recipient_zip_archive_with_field_password_and_rule_exclusions(self) -> None:
|
|
headers, login = self._login()
|
|
user_id = login["user"]["id"]
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "zip-attachments", "name": "ZIP attachments", "mode": "test"},
|
|
"fields": [
|
|
{"name": "invoice_number", "type": "string", "required": True},
|
|
{"name": "zip_password", "type": "password", "required": True},
|
|
{"name": "global_zip_password", "type": "password", "required": True},
|
|
],
|
|
"global_values": {"global_zip_password": "campaign-secret"},
|
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"allow_individual_to": True,
|
|
},
|
|
"template": {"subject": "Protected files", "text": "Please see the attached files."},
|
|
"attachments": {
|
|
"base_path": "documents",
|
|
"base_paths": [{
|
|
"id": "personal-documents",
|
|
"name": "My documents",
|
|
"source": f"managed:user:{user_id}",
|
|
"path": "documents",
|
|
"allow_individual": True,
|
|
}],
|
|
"zip": {
|
|
"enabled": True,
|
|
"archives": [
|
|
{
|
|
"id": "recipient-bundle",
|
|
"name": "bundle-{{local:invoice_number}}.zip",
|
|
"standard": True,
|
|
"password_enabled": True,
|
|
"password_field": "zip_password",
|
|
"password_scope": "local",
|
|
"method": "aes"
|
|
},
|
|
{
|
|
"id": "shared-bundle",
|
|
"name": "shared-files.zip",
|
|
"standard": False,
|
|
"password_enabled": True,
|
|
"password_field": "global_zip_password",
|
|
"password_scope": "global",
|
|
"method": "aes"
|
|
}
|
|
]
|
|
},
|
|
"global": [{
|
|
"id": "guide",
|
|
"label": "Guide outside ZIP",
|
|
"base_path_id": "personal-documents",
|
|
"base_dir": "documents",
|
|
"file_filter": "guide.pdf",
|
|
"required": True,
|
|
"zip": {"mode": "exclude"},
|
|
}],
|
|
"allow_individual": True,
|
|
},
|
|
"entries": {"inline": [{
|
|
"id": "recipient-zip",
|
|
"to": [{"email": "recipient@example.org", "name": "Recipient", "type": "to"}],
|
|
"fields": {"invoice_number": "2026-001", "zip_password": "recipient-secret"},
|
|
"attachments": [
|
|
{
|
|
"id": "invoice",
|
|
"label": "Invoice in ZIP",
|
|
"base_path_id": "personal-documents",
|
|
"base_dir": "documents",
|
|
"file_filter": "invoice.pdf",
|
|
"required": True,
|
|
"zip": {"mode": "inherit"},
|
|
},
|
|
{
|
|
"id": "invoice-outside",
|
|
"label": "Invoice outside ZIP as well",
|
|
"base_path_id": "personal-documents",
|
|
"base_dir": "documents",
|
|
"file_filter": "invoice.pdf",
|
|
"required": True,
|
|
"zip": {"archive_id": "exclude"},
|
|
},
|
|
{
|
|
"id": "cover",
|
|
"label": "Cover in shared ZIP",
|
|
"base_path_id": "personal-documents",
|
|
"base_dir": "documents",
|
|
"file_filter": "cover.txt",
|
|
"required": True,
|
|
"zip": {"archive_id": "shared-bundle"},
|
|
},
|
|
{
|
|
"id": "receipt",
|
|
"label": "Receipt outside ZIP",
|
|
"base_path_id": "personal-documents",
|
|
"base_dir": "documents",
|
|
"file_filter": "receipt.txt",
|
|
"required": True,
|
|
"zip": {"archive_id": "exclude"}
|
|
},
|
|
],
|
|
}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block", "missing_required_attachment": "block"},
|
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
|
|
for filename, content, content_type in [
|
|
("guide.pdf", b"global guide", "application/pdf"),
|
|
("invoice.pdf", b"secret invoice", "application/pdf"),
|
|
("cover.txt", b"shared cover", "text/plain"),
|
|
("receipt.txt", b"outside receipt", "text/plain"),
|
|
]:
|
|
uploaded = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=headers,
|
|
data={"owner_type": "user", "owner_id": user_id, "path": "documents", "campaign_id": campaign_id},
|
|
files=[("files", (filename, content, content_type))],
|
|
)
|
|
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
|
|
|
preview = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
|
headers=headers,
|
|
json={"include_unmatched": True},
|
|
)
|
|
self.assertEqual(preview.status_code, 200, preview.text)
|
|
by_id = {rule["attachment_id"]: rule for rule in preview.json()["rules"]}
|
|
self.assertFalse(by_id["guide"]["zip_included"])
|
|
self.assertTrue(by_id["invoice"]["zip_included"])
|
|
self.assertFalse(by_id["invoice-outside"]["zip_included"])
|
|
self.assertTrue(by_id["cover"]["zip_included"])
|
|
self.assertEqual(by_id["cover"]["zip_archive_id"], "shared-bundle")
|
|
self.assertFalse(by_id["receipt"]["zip_included"])
|
|
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": True},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
|
|
built = self.client.post(
|
|
f"/api/v1/campaigns/versions/{version_id}/build",
|
|
headers=headers,
|
|
json={"write_eml": True},
|
|
)
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
message_summary = built.json()["messages"][0]
|
|
self.assertEqual(message_summary["attachment_count"], 5)
|
|
summaries = {item["attachment_id"]: item for item in message_summary["attachments"]}
|
|
self.assertEqual(summaries["invoice"]["zip_filename"], "bundle-2026-001.zip")
|
|
self.assertIsNone(summaries["guide"]["zip_filename"])
|
|
self.assertIsNone(summaries["invoice-outside"]["zip_filename"])
|
|
self.assertEqual(summaries["cover"]["zip_filename"], "shared-files.zip")
|
|
self.assertIsNone(summaries["receipt"]["zip_filename"])
|
|
|
|
from govoplan_files.backend.db.models import CampaignAttachmentUse
|
|
from govoplan_campaign.backend.db.models import CampaignJob
|
|
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id).one()
|
|
eml_path = Path(job.eml_local_path)
|
|
message = BytesParser(policy=policy.default).parsebytes(eml_path.read_bytes())
|
|
uses = (
|
|
session.query(CampaignAttachmentUse)
|
|
.filter(
|
|
CampaignAttachmentUse.campaign_job_id == job.id,
|
|
CampaignAttachmentUse.use_stage == "built",
|
|
)
|
|
.all()
|
|
)
|
|
self.assertEqual(len(uses), 4)
|
|
self.assertEqual(sum(use.filename_used == "invoice.pdf" for use in uses), 1)
|
|
|
|
attachments = {part.get_filename(): part.get_payload(decode=True) for part in message.iter_attachments()}
|
|
self.assertEqual(set(attachments), {"guide.pdf", "invoice.pdf", "receipt.txt", "bundle-2026-001.zip", "shared-files.zip"})
|
|
self.assertEqual(attachments["guide.pdf"], b"global guide")
|
|
self.assertEqual(attachments["invoice.pdf"], b"secret invoice")
|
|
self.assertEqual(attachments["receipt.txt"], b"outside receipt")
|
|
with pyzipper.AESZipFile(io.BytesIO(attachments["bundle-2026-001.zip"])) as archive:
|
|
archive.setpassword(b"recipient-secret")
|
|
self.assertEqual(archive.namelist(), ["invoice.pdf"])
|
|
self.assertEqual(archive.read("invoice.pdf"), b"secret invoice")
|
|
with pyzipper.AESZipFile(io.BytesIO(attachments["shared-files.zip"])) as archive:
|
|
archive.setpassword(b"campaign-secret")
|
|
self.assertEqual(archive.namelist(), ["cover.txt"])
|
|
self.assertEqual(archive.read("cover.txt"), b"shared cover")
|
|
|
|
|
|
|
|
def test_execution_snapshot_freezes_delivery_configuration_and_job_manifest(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="snapshot-freeze",
|
|
)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion
|
|
|
|
with SessionLocal() as session:
|
|
version = session.get(CampaignVersion, version_id)
|
|
self.assertIsNotNone(version)
|
|
assert version is not None
|
|
snapshot = version.execution_snapshot
|
|
self.assertIsInstance(snapshot, dict)
|
|
self.assertEqual(snapshot["snapshot_version"], "3")
|
|
self.assertEqual(snapshot["job_count"], 1)
|
|
self.assertEqual(snapshot["queueable_job_count"], 1)
|
|
self.assertTrue(snapshot["job_manifest_sha256"])
|
|
self.assertTrue(snapshot["smtp_config_fingerprint"])
|
|
self.assertIsNone(snapshot["smtp"].get("password"))
|
|
self.assertTrue(version.execution_snapshot_hash)
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
|
self.assertTrue(job.eml_sha256)
|
|
self.assertTrue(job.message_id_header)
|
|
|
|
# Simulate accidental/config-drift mutation after the build. Sending
|
|
# must still use the immutable mock SMTP snapshot, not raw_json.
|
|
mutated = json.loads(json.dumps(version.raw_json))
|
|
mutated["server"]["smtp"]["host"] = "smtp.example.invalid"
|
|
version.raw_json = mutated
|
|
session.add(version)
|
|
session.commit()
|
|
|
|
sent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/send-now",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"validate_before_send": False,
|
|
"build_before_send": False,
|
|
"use_rate_limit": False,
|
|
"enqueue_imap_task": False,
|
|
},
|
|
)
|
|
self.assertEqual(sent.status_code, 200, sent.text)
|
|
self.assertEqual(sent.json()["result"]["sent_count"], 1, sent.text)
|
|
self.assertEqual(sent.json()["result"]["outcome_unknown_count"], 0, sent.text)
|
|
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
|
self.assertEqual(job.send_status, "smtp_accepted")
|
|
|
|
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="exact-eml-parity",
|
|
)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignJob
|
|
from govoplan_mail.backend.dev.mock_mailbox import list_records
|
|
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
|
self.assertIsNotNone(job.eml_local_path)
|
|
generated_eml = Path(job.eml_local_path).read_bytes()
|
|
|
|
sent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/send-now",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"validate_before_send": False,
|
|
"build_before_send": False,
|
|
"use_rate_limit": False,
|
|
"enqueue_imap_task": False,
|
|
},
|
|
)
|
|
self.assertEqual(sent.status_code, 200, sent.text)
|
|
|
|
records = list_records(kind="smtp")
|
|
self.assertEqual(len(records), 1)
|
|
raw_filename = records[0].get("raw_filename")
|
|
self.assertTrue(raw_filename)
|
|
captured_eml = (_TEST_ROOT / "mock-mailbox" / "messages" / str(raw_filename)).read_bytes()
|
|
self.assertEqual(captured_eml, generated_eml)
|
|
|
|
def test_send_now_rejects_modified_generated_eml_before_delivery(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="tampered-eml",
|
|
)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignJob
|
|
from govoplan_mail.backend.dev.mock_mailbox import list_records
|
|
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
|
self.assertIsNotNone(job.eml_local_path)
|
|
eml_path = Path(job.eml_local_path)
|
|
eml_path.write_bytes(eml_path.read_bytes() + b"\r\nX-Tampered: true\r\n")
|
|
|
|
sent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/send-now",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"validate_before_send": False,
|
|
"build_before_send": False,
|
|
"use_rate_limit": False,
|
|
"enqueue_imap_task": False,
|
|
},
|
|
)
|
|
self.assertEqual(sent.status_code, 200, sent.text)
|
|
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
|
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
|
|
self.assertEqual(list_records(kind="smtp"), [])
|
|
|
|
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
|
headers, _ = self._login()
|
|
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
|
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "partial-refusal", "name": "Partial refusal", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {
|
|
"smtp": {
|
|
"host": "mock.smtp",
|
|
"port": 2525,
|
|
"username": "sender@example.org",
|
|
"password": "test-secret",
|
|
"security": "starttls",
|
|
}
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"allow_individual_to": True,
|
|
},
|
|
"template": {"subject": "Partial", "text": "Partial"},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {
|
|
"inline": [
|
|
{
|
|
"id": "one",
|
|
"to": [
|
|
{"email": "accepted@example.org", "type": "to"},
|
|
{"email": "reject-me@example.org", "type": "to"},
|
|
],
|
|
"fields": {},
|
|
}
|
|
]
|
|
},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"rate_limit": {"messages_per_minute": 60}, "imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
built = self.client.post(f"/api/v1/campaigns/versions/{version_id}/build", headers=headers, json={"write_eml": True})
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
|
|
try:
|
|
set_failures(smtp_reject_recipients_containing="reject-me")
|
|
sent = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/send-now",
|
|
headers=headers,
|
|
json={
|
|
"version_id": version_id,
|
|
"validate_before_send": False,
|
|
"build_before_send": False,
|
|
"use_rate_limit": False,
|
|
"enqueue_imap_task": False,
|
|
},
|
|
)
|
|
self.assertEqual(sent.status_code, 200, sent.text)
|
|
result = sent.json()["result"]
|
|
self.assertEqual(result["sent_count"], 1, sent.text)
|
|
self.assertIn("refused recipients", result["results"][0]["message"])
|
|
finally:
|
|
set_failures(smtp_reject_recipients_containing=None)
|
|
|
|
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
|
with SessionLocal() as session:
|
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
|
self.assertEqual(job.send_status, "smtp_accepted")
|
|
self.assertIn("reject-me@example.org", job.last_error or "")
|
|
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
|
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
|
|
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
|
|
|
|
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="worker-loss",
|
|
recipient_count=2,
|
|
)
|
|
queued = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/queue",
|
|
headers=headers,
|
|
json={"version_id": version_id, "enqueue_celery": False},
|
|
)
|
|
self.assertEqual(queued.status_code, 200, queued.text)
|
|
self.assertEqual(queued.json()["queued_count"], 2)
|
|
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
|
|
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
|
|
|
with SessionLocal() as session:
|
|
jobs = (
|
|
session.query(CampaignJob)
|
|
.filter(CampaignJob.campaign_version_id == version_id)
|
|
.order_by(CampaignJob.entry_index.asc())
|
|
.all()
|
|
)
|
|
uncertain_job = jobs[0]
|
|
now = datetime.now(timezone.utc)
|
|
uncertain_job.queue_status = "sending"
|
|
uncertain_job.send_status = "sending"
|
|
uncertain_job.claimed_at = now
|
|
uncertain_job.smtp_started_at = now
|
|
uncertain_job.claim_token = "worker-loss-claim"
|
|
uncertain_job.attempt_count = 1
|
|
session.add(
|
|
SendAttempt(
|
|
job_id=uncertain_job.id,
|
|
attempt_number=1,
|
|
status="smtp_in_progress",
|
|
claim_token=uncertain_job.claim_token,
|
|
started_at=now,
|
|
)
|
|
)
|
|
session.add(uncertain_job)
|
|
session.commit()
|
|
uncertain_job_id = uncertain_job.id
|
|
|
|
with SessionLocal() as session:
|
|
result = send_campaign_job(session, job_id=uncertain_job_id, use_rate_limit=False)
|
|
self.assertEqual(result.status, "outcome_unknown")
|
|
|
|
retry_unknown = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
|
headers=headers,
|
|
json={"version_id": version_id, "job_ids": [uncertain_job_id], "enqueue_celery": False},
|
|
)
|
|
self.assertEqual(retry_unknown.status_code, 200, retry_unknown.text)
|
|
self.assertEqual(retry_unknown.json()["result"]["selected_count"], 0)
|
|
|
|
cancelled = self.client.post(f"/api/v1/campaigns/{campaign_id}/cancel", headers=headers)
|
|
self.assertEqual(cancelled.status_code, 200, cancelled.text)
|
|
with SessionLocal() as session:
|
|
campaign = session.get(Campaign, campaign_id)
|
|
version = session.get(CampaignVersion, version_id)
|
|
self.assertEqual(campaign.status, "outcome_unknown")
|
|
self.assertEqual(version.workflow_state, "outcome_unknown")
|
|
|
|
page = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
|
headers=headers,
|
|
params={"version_id": version_id, "page": 1, "page_size": 1},
|
|
)
|
|
self.assertEqual(page.status_code, 200, page.text)
|
|
self.assertEqual(page.json()["total"], 2)
|
|
self.assertEqual(page.json()["pages"], 2)
|
|
self.assertEqual(page.json()["counts"]["send"]["outcome_unknown"], 1)
|
|
self.assertNotIn("resolved_recipients", page.json()["jobs"][0])
|
|
|
|
filtered_page = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
|
headers=headers,
|
|
params={"version_id": version_id, "send_status": "outcome_unknown"},
|
|
)
|
|
self.assertEqual(filtered_page.status_code, 200, filtered_page.text)
|
|
self.assertEqual(filtered_page.json()["total"], 1)
|
|
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
|
|
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
|
|
|
|
detail = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(detail.status_code, 200, detail.text)
|
|
self.assertIn("resolved_recipients", detail.json()["job"])
|
|
self.assertEqual(detail.json()["attempts"]["smtp"][0]["status"], "outcome_unknown")
|
|
|
|
resolved = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}/resolve-outcome",
|
|
headers=headers,
|
|
json={"decision": "not_sent", "note": "Verified against the SMTP logs."},
|
|
)
|
|
self.assertEqual(resolved.status_code, 200, resolved.text)
|
|
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
|
|
|
|
retry = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
|
|
headers=headers,
|
|
json={"version_id": version_id, "job_ids": [uncertain_job_id], "enqueue_celery": False},
|
|
)
|
|
self.assertEqual(retry.status_code, 200, retry.text)
|
|
self.assertEqual(retry.json()["result"]["selected_count"], 1)
|
|
with SessionLocal() as session:
|
|
job = session.get(CampaignJob, uncertain_job_id)
|
|
self.assertEqual(job.send_status, "queued")
|
|
|
|
def test_mixed_delivery_results_are_explicitly_partially_completed(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="partial-result",
|
|
recipient_count=2,
|
|
)
|
|
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
|
from govoplan_campaign.backend.sending.jobs import _update_campaign_after_job
|
|
|
|
with SessionLocal() as session:
|
|
jobs = (
|
|
session.query(CampaignJob)
|
|
.filter(CampaignJob.campaign_version_id == version_id)
|
|
.order_by(CampaignJob.entry_index.asc())
|
|
.all()
|
|
)
|
|
jobs[0].send_status = "smtp_accepted"
|
|
jobs[0].sent_at = datetime.now(timezone.utc)
|
|
jobs[1].send_status = "failed_permanent"
|
|
jobs[1].last_error = "Explicit SMTP rejection"
|
|
for job in jobs:
|
|
job.queue_status = "draft"
|
|
session.add(job)
|
|
_update_campaign_after_job(session, campaign_id, version_id)
|
|
session.commit()
|
|
|
|
campaign = session.get(Campaign, campaign_id)
|
|
version = session.get(CampaignVersion, version_id)
|
|
self.assertEqual(campaign.status, "partially_completed")
|
|
self.assertEqual(version.workflow_state, "partially_completed")
|
|
|
|
report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers)
|
|
self.assertEqual(report.status_code, 200, report.text)
|
|
cards = report.json()["cards"]
|
|
self.assertEqual(cards["smtp_accepted"], 1)
|
|
self.assertEqual(cards["failed"], 1)
|
|
self.assertTrue(cards["partially_completed"])
|
|
|
|
|
|
def test_reports_and_job_review_are_scoped_to_the_selected_version(self) -> None:
|
|
headers, _ = self._login()
|
|
campaign_id, first_version_id = self._create_built_delivery_campaign(
|
|
headers,
|
|
external_id="version-scoped-report",
|
|
recipient_count=1,
|
|
)
|
|
|
|
locked = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/lock-permanently",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(locked.status_code, 200, locked.text)
|
|
copied = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{first_version_id}/fork",
|
|
headers=headers,
|
|
json={},
|
|
)
|
|
self.assertEqual(copied.status_code, 200, copied.text)
|
|
second_version_id = copied.json()["version"]["id"]
|
|
|
|
version_response = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(version_response.status_code, 200, version_response.text)
|
|
campaign_json = version_response.json()["raw_json"]
|
|
campaign_json["entries"]["inline"].append(
|
|
{
|
|
"id": "recipient-2",
|
|
"to": [{"email": "recipient-2@example.org", "type": "to"}],
|
|
"fields": {"first_name": "Recipient 2"},
|
|
}
|
|
)
|
|
updated = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{second_version_id}",
|
|
headers=headers,
|
|
json={"campaign_json": campaign_json},
|
|
)
|
|
self.assertEqual(updated.status_code, 200, updated.text)
|
|
validated = self.client.post(
|
|
f"/api/v1/campaigns/versions/{second_version_id}/validate",
|
|
headers=headers,
|
|
json={"check_files": False},
|
|
)
|
|
self.assertEqual(validated.status_code, 200, validated.text)
|
|
self.assertTrue(validated.json()["ok"], validated.text)
|
|
built = self.client.post(
|
|
f"/api/v1/campaigns/versions/{second_version_id}/build",
|
|
headers=headers,
|
|
json={"write_eml": True},
|
|
)
|
|
self.assertEqual(built.status_code, 200, built.text)
|
|
self.assertEqual(built.json()["built_count"], 2, built.text)
|
|
|
|
first_report = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/report",
|
|
headers=headers,
|
|
params={"version_id": first_version_id},
|
|
)
|
|
second_report = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/report",
|
|
headers=headers,
|
|
params={"version_id": second_version_id},
|
|
)
|
|
self.assertEqual(first_report.status_code, 200, first_report.text)
|
|
self.assertEqual(second_report.status_code, 200, second_report.text)
|
|
self.assertEqual(first_report.json()["selected_version_id"], first_version_id)
|
|
self.assertEqual(first_report.json()["cards"]["jobs_total"], 1)
|
|
self.assertEqual(second_report.json()["selected_version_id"], second_version_id)
|
|
self.assertEqual(second_report.json()["cards"]["jobs_total"], 2)
|
|
|
|
first_jobs = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/jobs",
|
|
headers=headers,
|
|
params={"version_id": first_version_id, "page_size": 1},
|
|
)
|
|
self.assertEqual(first_jobs.status_code, 200, first_jobs.text)
|
|
self.assertEqual(first_jobs.json()["total"], 1)
|
|
self.assertEqual(first_jobs.json()["total_unfiltered"], 1)
|
|
self.assertEqual(first_jobs.json()["review"]["required_count"], 0)
|
|
self.assertIn("reviewed", first_jobs.json()["jobs"][0])
|
|
self.assertNotIn("resolved_recipients", first_jobs.json()["jobs"][0])
|
|
|
|
first_csv = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
|
|
headers=headers,
|
|
params={"version_id": first_version_id},
|
|
)
|
|
second_csv = self.client.get(
|
|
f"/api/v1/campaigns/{campaign_id}/report/jobs.csv",
|
|
headers=headers,
|
|
params={"version_id": second_version_id},
|
|
)
|
|
self.assertEqual(first_csv.status_code, 200, first_csv.text)
|
|
self.assertEqual(second_csv.status_code, 200, second_csv.text)
|
|
self.assertEqual(len(first_csv.text.strip().splitlines()), 2)
|
|
self.assertEqual(len(second_csv.text.strip().splitlines()), 3)
|
|
|
|
emailed = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/report/email",
|
|
headers=headers,
|
|
json={
|
|
"version_id": first_version_id,
|
|
"to": ["auditor@example.org"],
|
|
"attach_jobs_csv": True,
|
|
"dry_run": True,
|
|
},
|
|
)
|
|
self.assertEqual(emailed.status_code, 200, emailed.text)
|
|
self.assertEqual(emailed.json()["result"]["version_id"], first_version_id)
|
|
self.assertTrue(emailed.json()["result"]["attached_jobs_csv"])
|
|
self.assertFalse(emailed.json()["result"]["sent"])
|
|
|
|
|
|
def test_tenant_user_group_role_and_api_key_administration(self) -> None:
|
|
headers, login = self._login()
|
|
system_headers, _ = self._login()
|
|
|
|
overview = self.client.get("/api/v1/admin/overview", headers=headers)
|
|
self.assertEqual(overview.status_code, 200, overview.text)
|
|
self.assertEqual(overview.json()["tenant_count"], 1)
|
|
|
|
created_tenant = self.client.post(
|
|
"/api/v1/admin/tenants",
|
|
headers=headers,
|
|
json={
|
|
"slug": "operations",
|
|
"name": "Operations",
|
|
"description": "Operational tenant",
|
|
"default_locale": "de",
|
|
"settings": {},
|
|
},
|
|
)
|
|
self.assertEqual(created_tenant.status_code, 201, created_tenant.text)
|
|
tenant_id = created_tenant.json()["id"]
|
|
|
|
# Suspending the tenant that backs the current session is rejected to
|
|
# prevent an administrator from invalidating the only context through
|
|
# which they can repair the instance. Switch first, then suspend.
|
|
reject_active_tenant_suspend = self.client.patch(
|
|
f"/api/v1/admin/tenants/{login['tenant']['id']}",
|
|
headers=system_headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(reject_active_tenant_suspend.status_code, 409, reject_active_tenant_suspend.text)
|
|
|
|
# System roles do not bypass tenant context. Tenant administration must
|
|
# use a membership-backed active tenant selected on the session.
|
|
cross_tenant_users = self.client.get(
|
|
"/api/v1/admin/users",
|
|
headers=system_headers,
|
|
params={"tenant_id": tenant_id},
|
|
)
|
|
self.assertEqual(cross_tenant_users.status_code, 409, cross_tenant_users.text)
|
|
|
|
switched = self.client.post(
|
|
"/api/v1/auth/switch-tenant",
|
|
headers=headers,
|
|
json={"tenant_id": tenant_id},
|
|
)
|
|
self.assertEqual(switched.status_code, 200, switched.text)
|
|
self.assertEqual(switched.json()["active_tenant"]["id"], tenant_id)
|
|
switched_scopes = switched.json()["scopes"]
|
|
self.assertIn("tenant:*", switched_scopes)
|
|
self.assertIn("system:*", switched_scopes)
|
|
|
|
roles = self.client.get("/api/v1/admin/roles", headers=headers)
|
|
self.assertEqual(roles.status_code, 200, roles.text)
|
|
owner_role = next(role for role in roles.json()["roles"] if role["slug"] == "owner")
|
|
|
|
custom_role = self.client.post(
|
|
"/api/v1/admin/roles",
|
|
headers=headers,
|
|
json={
|
|
"slug": "file-reader",
|
|
"name": "File reader",
|
|
"description": "Read managed files only",
|
|
"permissions": ["files:read"],
|
|
},
|
|
)
|
|
self.assertEqual(custom_role.status_code, 201, custom_role.text)
|
|
custom_role_id = custom_role.json()["id"]
|
|
|
|
group = self.client.post(
|
|
"/api/v1/admin/groups",
|
|
headers=headers,
|
|
json={
|
|
"slug": "file-reviewers",
|
|
"name": "File reviewers",
|
|
"description": "Read-only file access",
|
|
"member_ids": [],
|
|
"role_ids": [custom_role_id],
|
|
},
|
|
)
|
|
self.assertEqual(group.status_code, 201, group.text)
|
|
group_id = group.json()["id"]
|
|
|
|
created_user = self.client.post(
|
|
"/api/v1/admin/users",
|
|
headers=headers,
|
|
json={
|
|
"email": "reader@example.local",
|
|
"display_name": "Reader",
|
|
"password": "reader-password-123",
|
|
"password_reset_required": False,
|
|
"is_active": True,
|
|
"group_ids": [group_id],
|
|
"role_ids": [],
|
|
},
|
|
)
|
|
self.assertEqual(created_user.status_code, 201, created_user.text)
|
|
reader_id = created_user.json()["user"]["id"]
|
|
self.assertEqual(created_user.json()["temporary_password"], "reader-password-123")
|
|
self.assertEqual(created_user.json()["user"]["roles"], [])
|
|
self.assertIn("files:read", created_user.json()["user"]["effective_scopes"])
|
|
|
|
reader_login = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "reader@example.local",
|
|
"password": "reader-password-123",
|
|
"tenant_slug": "operations",
|
|
},
|
|
)
|
|
self.assertEqual(reader_login.status_code, 200, reader_login.text)
|
|
reader_headers = {"Authorization": f"Bearer {reader_login.json()['access_token']}"}
|
|
|
|
file_spaces = self.client.get("/api/v1/files/spaces", headers=reader_headers)
|
|
self.assertEqual(file_spaces.status_code, 200, file_spaces.text)
|
|
denied_admin = self.client.get("/api/v1/admin/users", headers=reader_headers)
|
|
self.assertEqual(denied_admin.status_code, 403, denied_admin.text)
|
|
|
|
excessive_key = self.client.post(
|
|
"/api/v1/admin/api-keys",
|
|
headers=headers,
|
|
json={
|
|
"name": "Too broad",
|
|
"user_id": reader_id,
|
|
"scopes": ["campaign:send"],
|
|
},
|
|
)
|
|
self.assertEqual(excessive_key.status_code, 422, excessive_key.text)
|
|
|
|
reader_key = self.client.post(
|
|
"/api/v1/admin/api-keys",
|
|
headers=headers,
|
|
json={
|
|
"name": "Reader key",
|
|
"user_id": reader_id,
|
|
"scopes": ["files:read"],
|
|
},
|
|
)
|
|
self.assertEqual(reader_key.status_code, 201, reader_key.text)
|
|
api_headers = {"X-API-Key": reader_key.json()["secret"]}
|
|
self.assertEqual(self.client.get("/api/v1/files/spaces", headers=api_headers).status_code, 200)
|
|
self.assertEqual(self.client.get("/api/v1/campaigns", headers=api_headers).status_code, 403)
|
|
|
|
# API keys are bounded by the owning membership's live permissions.
|
|
# Removing the group's role must immediately narrow the existing key.
|
|
narrowed_group = self.client.patch(
|
|
f"/api/v1/admin/groups/{group_id}",
|
|
headers=headers,
|
|
json={"role_ids": []},
|
|
)
|
|
self.assertEqual(narrowed_group.status_code, 200, narrowed_group.text)
|
|
self.assertEqual(self.client.get("/api/v1/files/spaces", headers=api_headers).status_code, 403)
|
|
|
|
# The active tenant must retain an operational owner. The only owner is
|
|
# the system administrator's automatically created membership.
|
|
remove_last_owner = self.client.patch(
|
|
f"/api/v1/admin/users/{login['user']['id']}",
|
|
headers=headers,
|
|
json={"group_ids": [], "role_ids": []},
|
|
)
|
|
# The original login user id belongs to the default tenant, so the
|
|
# operations tenant correctly hides it rather than crossing tenant scope.
|
|
self.assertEqual(remove_last_owner.status_code, 404, remove_last_owner.text)
|
|
|
|
operations_users = self.client.get("/api/v1/admin/users", headers=headers)
|
|
self.assertEqual(operations_users.status_code, 200, operations_users.text)
|
|
operation_admin = next(user for user in operations_users.json()["users"] if user["email"] == "admin@example.local")
|
|
self.assertTrue(any(role["id"] == owner_role["id"] for role in operation_admin["roles"]))
|
|
remove_last_owner = self.client.patch(
|
|
f"/api/v1/admin/users/{operation_admin['id']}",
|
|
headers=headers,
|
|
json={"group_ids": [], "role_ids": []},
|
|
)
|
|
self.assertEqual(remove_last_owner.status_code, 409, remove_last_owner.text)
|
|
|
|
tenant_owner = self.client.post(
|
|
"/api/v1/admin/users",
|
|
headers=headers,
|
|
json={
|
|
"email": "tenant-owner@example.local",
|
|
"display_name": "Tenant Owner",
|
|
"password": "tenant-owner-password",
|
|
"password_reset_required": False,
|
|
"is_active": True,
|
|
"group_ids": [],
|
|
"role_ids": [owner_role["id"]],
|
|
},
|
|
)
|
|
self.assertEqual(tenant_owner.status_code, 201, tenant_owner.text)
|
|
tenant_owner_account_id = tenant_owner.json()["user"]["account_id"]
|
|
tenant_owner_login = self.client.post(
|
|
"/api/v1/auth/login",
|
|
json={
|
|
"email": "tenant-owner@example.local",
|
|
"password": "tenant-owner-password",
|
|
"tenant_slug": "operations",
|
|
},
|
|
)
|
|
self.assertEqual(tenant_owner_login.status_code, 200, tenant_owner_login.text)
|
|
tenant_owner_scopes = tenant_owner_login.json()["scopes"]
|
|
self.assertIn("tenant:*", tenant_owner_scopes)
|
|
self.assertNotIn("system:*", tenant_owner_scopes)
|
|
tenant_owner_headers = {"Authorization": f"Bearer {tenant_owner_login.json()['access_token']}"}
|
|
self.assertEqual(self.client.get("/api/v1/admin/users", headers=tenant_owner_headers).status_code, 200)
|
|
self.assertEqual(self.client.get("/api/v1/admin/tenants", headers=tenant_owner_headers).status_code, 403)
|
|
|
|
# Global account suspension immediately invalidates all of its tenant
|
|
# sessions. Reactivation restores access without changing memberships.
|
|
disabled_tenant_owner = self.client.patch(
|
|
f"/api/v1/admin/system/accounts/{tenant_owner_account_id}",
|
|
headers=system_headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(disabled_tenant_owner.status_code, 200, disabled_tenant_owner.text)
|
|
self.assertEqual(self.client.get("/api/v1/auth/me", headers=tenant_owner_headers).status_code, 401)
|
|
reenabled_tenant_owner = self.client.patch(
|
|
f"/api/v1/admin/system/accounts/{tenant_owner_account_id}",
|
|
headers=system_headers,
|
|
json={"is_active": True},
|
|
)
|
|
self.assertEqual(reenabled_tenant_owner.status_code, 200, reenabled_tenant_owner.text)
|
|
self.assertEqual(self.client.get("/api/v1/auth/me", headers=tenant_owner_headers).status_code, 200)
|
|
|
|
system_accounts = self.client.get("/api/v1/admin/system/accounts", headers=system_headers)
|
|
self.assertEqual(system_accounts.status_code, 200, system_accounts.text)
|
|
admin_account = next(account for account in system_accounts.json()["accounts"] if account["email"] == "admin@example.local")
|
|
remove_last_system_owner = self.client.put(
|
|
f"/api/v1/admin/system/accounts/{admin_account['account_id']}/roles",
|
|
headers=system_headers,
|
|
json={"role_ids": []},
|
|
)
|
|
self.assertEqual(remove_last_system_owner.status_code, 409, remove_last_system_owner.text)
|
|
deactivate_last_system_owner = self.client.patch(
|
|
f"/api/v1/admin/system/accounts/{admin_account['account_id']}",
|
|
headers=system_headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(deactivate_last_system_owner.status_code, 409, deactivate_last_system_owner.text)
|
|
|
|
suspended = self.client.patch(
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=system_headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(suspended.status_code, 200, suspended.text)
|
|
self.assertEqual(self.client.get("/api/v1/auth/me", headers=headers).status_code, 401)
|
|
reactivated = self.client.patch(
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=system_headers,
|
|
json={"is_active": True},
|
|
)
|
|
self.assertEqual(reactivated.status_code, 200, reactivated.text)
|
|
self.assertEqual(self.client.get("/api/v1/auth/me", headers=headers).status_code, 200)
|
|
|
|
audit = self.client.get("/api/v1/admin/audit", headers=headers)
|
|
self.assertEqual(audit.status_code, 200, audit.text)
|
|
actions = {item["action"] for item in audit.json()["items"]}
|
|
self.assertIn("user.created", actions)
|
|
self.assertIn("group.created", actions)
|
|
self.assertIn("role.created", actions)
|
|
|
|
|
|
|
|
def test_system_governance_defaults_templates_and_central_accounts(self) -> None:
|
|
headers, login = self._login()
|
|
tenant_id = login["tenant"]["id"]
|
|
|
|
settings_response = self.client.get("/api/v1/admin/system/settings", headers=headers)
|
|
self.assertEqual(settings_response.status_code, 200, settings_response.text)
|
|
self.assertTrue(settings_response.json()["allow_tenant_custom_groups"])
|
|
self.assertEqual(settings_response.json()["privacy_retention_policy"]["audit_detail_level"], "full")
|
|
|
|
deny_local_groups = self.client.patch(
|
|
"/api/v1/admin/system/settings",
|
|
headers=headers,
|
|
json={
|
|
"default_locale": "en",
|
|
"allow_tenant_custom_groups": False,
|
|
"allow_tenant_custom_roles": True,
|
|
"allow_tenant_api_keys": True,
|
|
"privacy_retention_policy": {
|
|
"audit_detail_level": "redacted",
|
|
"mock_mailbox_retention_days": 0,
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(deny_local_groups.status_code, 200, deny_local_groups.text)
|
|
self.assertEqual(deny_local_groups.json()["privacy_retention_policy"]["audit_detail_level"], "redacted")
|
|
self.assertEqual(deny_local_groups.json()["privacy_retention_policy"]["mock_mailbox_retention_days"], 0)
|
|
|
|
widened_tenant = self.client.patch(
|
|
f"/api/v1/admin/tenants/{tenant_id}",
|
|
headers=headers,
|
|
json={"allow_custom_groups": True},
|
|
)
|
|
self.assertEqual(widened_tenant.status_code, 422, widened_tenant.text)
|
|
|
|
locked_retention = self.client.put(
|
|
"/api/v1/admin/privacy-retention/policies/system",
|
|
headers=headers,
|
|
json={"policy": {"allow_lower_level_limits": {"raw_campaign_json_retention_days": False}}},
|
|
)
|
|
self.assertEqual(locked_retention.status_code, 200, locked_retention.text)
|
|
self.assertFalse(locked_retention.json()["effective_policy"]["allow_lower_level_limits"]["raw_campaign_json_retention_days"])
|
|
tenant_retention_widen = self.client.put(
|
|
"/api/v1/admin/privacy-retention/policies/tenant",
|
|
headers=headers,
|
|
json={"policy": {"raw_campaign_json_retention_days": 1}},
|
|
)
|
|
self.assertEqual(tenant_retention_widen.status_code, 422, tenant_retention_widen.text)
|
|
tenant_retention_unlock = self.client.put(
|
|
"/api/v1/admin/privacy-retention/policies/tenant",
|
|
headers=headers,
|
|
json={"policy": {"allow_lower_level_limits": {"raw_campaign_json_retention_days": True}}},
|
|
)
|
|
self.assertEqual(tenant_retention_unlock.status_code, 422, tenant_retention_unlock.text)
|
|
|
|
retention_dry_run = self.client.post(
|
|
"/api/v1/admin/system/retention/run",
|
|
headers=headers,
|
|
json={"dry_run": True},
|
|
)
|
|
self.assertEqual(retention_dry_run.status_code, 200, retention_dry_run.text)
|
|
self.assertTrue(retention_dry_run.json()["result"]["dry_run"])
|
|
self.assertIn("raw_campaign_json", retention_dry_run.json()["result"]["counts"])
|
|
|
|
blocked_group = self.client.post(
|
|
"/api/v1/admin/groups",
|
|
headers=headers,
|
|
json={"slug": "blocked-local", "name": "Blocked local group", "member_ids": [], "role_ids": []},
|
|
)
|
|
self.assertEqual(blocked_group.status_code, 409, blocked_group.text)
|
|
|
|
central_group = self.client.post(
|
|
"/api/v1/admin/system/governance-templates",
|
|
headers=headers,
|
|
json={
|
|
"kind": "group",
|
|
"slug": "case-workers",
|
|
"name": "Case workers",
|
|
"description": "System-controlled group identity",
|
|
"permissions": [],
|
|
"is_active": True,
|
|
"assignments": [{"tenant_id": tenant_id, "mode": "required"}],
|
|
},
|
|
)
|
|
self.assertEqual(central_group.status_code, 201, central_group.text)
|
|
template_id = central_group.json()["id"]
|
|
|
|
tenant_groups = self.client.get("/api/v1/admin/groups", headers=headers)
|
|
self.assertEqual(tenant_groups.status_code, 200, tenant_groups.text)
|
|
materialized = next(group for group in tenant_groups.json()["groups"] if group["system_template_id"] == template_id)
|
|
self.assertTrue(materialized["system_required"])
|
|
self.assertTrue(materialized["is_active"])
|
|
|
|
reject_required_deactivation = self.client.patch(
|
|
f"/api/v1/admin/groups/{materialized['id']}",
|
|
headers=headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(reject_required_deactivation.status_code, 409, reject_required_deactivation.text)
|
|
|
|
created_tenant = self.client.post(
|
|
"/api/v1/admin/tenants",
|
|
headers=headers,
|
|
json={"slug": "governed", "name": "Governed", "settings": {}},
|
|
)
|
|
self.assertEqual(created_tenant.status_code, 201, created_tenant.text)
|
|
governed_tenant_id = created_tenant.json()["id"]
|
|
|
|
central_account = self.client.post(
|
|
"/api/v1/admin/system/accounts",
|
|
headers=headers,
|
|
json={
|
|
"email": "central-user@example.local",
|
|
"display_name": "Central User",
|
|
"password": "central-user-password",
|
|
"password_reset_required": False,
|
|
"is_active": True,
|
|
"role_ids": [],
|
|
"memberships": [{
|
|
"tenant_id": governed_tenant_id,
|
|
"is_active": True,
|
|
"role_ids": [],
|
|
"group_ids": [],
|
|
}],
|
|
},
|
|
)
|
|
self.assertEqual(central_account.status_code, 201, central_account.text)
|
|
memberships = central_account.json()["account"]["memberships"]
|
|
self.assertEqual([item["tenant_id"] for item in memberships], [governed_tenant_id])
|
|
|
|
audit = self.client.get(
|
|
"/api/v1/admin/audit",
|
|
headers=headers,
|
|
params={"all_tenants": True, "limit": 500},
|
|
)
|
|
self.assertEqual(audit.status_code, 200, audit.text)
|
|
actions = {item["action"] for item in audit.json()["items"]}
|
|
self.assertIn("system_settings.updated", actions)
|
|
self.assertIn("governance_template.created", actions)
|
|
self.assertIn("system_account.created", actions)
|
|
|
|
|
|
def test_refined_permissions_are_narrow_and_enforce_delegation_ceiling(self) -> None:
|
|
owner_headers, _ = self._login()
|
|
designer_role = self._create_role(
|
|
owner_headers,
|
|
slug="role-designer",
|
|
permissions=["admin:users:read", "admin:roles:read", "admin:roles:write", "campaign:queue"],
|
|
)
|
|
designer = self._create_user(
|
|
owner_headers,
|
|
email="designer@example.local",
|
|
password="designer-password",
|
|
role_ids=[str(designer_role["id"])],
|
|
)
|
|
designer_headers, designer_login = self._login_as(email="designer@example.local", password="designer-password")
|
|
|
|
self.assertIn("campaign:queue", designer_login["scopes"])
|
|
self.assertNotIn("campaign:retry", designer_login["scopes"])
|
|
self.assertNotIn("campaign:send", designer_login["scopes"])
|
|
|
|
excessive_role = self.client.post(
|
|
"/api/v1/admin/roles",
|
|
headers=designer_headers,
|
|
json={
|
|
"slug": "sender-escalation",
|
|
"name": "Sender escalation",
|
|
"description": "Must be rejected",
|
|
"permissions": ["campaign:send"],
|
|
},
|
|
)
|
|
self.assertEqual(excessive_role.status_code, 422, excessive_role.text)
|
|
|
|
assign_without_assignment_permission = self.client.patch(
|
|
f"/api/v1/admin/users/{designer['id']}",
|
|
headers=designer_headers,
|
|
json={"role_ids": []},
|
|
)
|
|
self.assertEqual(assign_without_assignment_permission.status_code, 403, assign_without_assignment_permission.text)
|
|
|
|
def test_campaign_acl_separates_capability_from_object_access(self) -> None:
|
|
owner_headers, _ = self._login()
|
|
access_role = self._create_role(
|
|
owner_headers,
|
|
slug="campaign-collaborator",
|
|
permissions=["campaign:read", "campaign:update", "recipients:read"],
|
|
)
|
|
reader = self._create_user(
|
|
owner_headers,
|
|
email="campaign-reader@example.local",
|
|
password="reader-password",
|
|
role_ids=[str(access_role["id"])],
|
|
)
|
|
other = self._create_user(
|
|
owner_headers,
|
|
email="campaign-other@example.local",
|
|
password="other-password",
|
|
role_ids=[str(access_role["id"])],
|
|
)
|
|
reader_headers, _ = self._login_as(email="campaign-reader@example.local", password="reader-password")
|
|
other_headers, _ = self._login_as(email="campaign-other@example.local", password="other-password")
|
|
|
|
config = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "acl-campaign", "name": "ACL campaign", "mode": "test"},
|
|
"fields": [],
|
|
"global_values": {},
|
|
"server": {"smtp": {"host": "mock.smtp", "port": 2525, "security": "starttls"}},
|
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}},
|
|
"template": {"subject": "ACL", "text": "ACL"},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {"inline": [{"id": "one", "to": [{"email": "one@example.org", "type": "to"}], "fields": {}}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"rate_limit": {"messages_per_minute": 60}, "imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
created = self.client.post("/api/v1/campaigns", headers=owner_headers, json={"config": config})
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
|
|
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=reader_headers).status_code, 403)
|
|
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403)
|
|
|
|
read_share = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/shares",
|
|
headers=owner_headers,
|
|
json={"target_type": "user", "target_id": reader["id"], "permission": "read"},
|
|
)
|
|
self.assertEqual(read_share.status_code, 201, read_share.text)
|
|
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=reader_headers).status_code, 200)
|
|
version_detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=reader_headers)
|
|
self.assertEqual(version_detail.status_code, 200, version_detail.text)
|
|
denied_write = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/set-step",
|
|
headers=reader_headers,
|
|
json={"current_step": "template"},
|
|
)
|
|
self.assertEqual(denied_write.status_code, 403, denied_write.text)
|
|
|
|
write_share = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/shares",
|
|
headers=owner_headers,
|
|
json={"target_type": "user", "target_id": reader["id"], "permission": "write"},
|
|
)
|
|
self.assertEqual(write_share.status_code, 201, write_share.text)
|
|
allowed_write = self.client.post(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}/set-step",
|
|
headers=reader_headers,
|
|
json={"current_step": "template"},
|
|
)
|
|
self.assertEqual(allowed_write.status_code, 200, allowed_write.text)
|
|
changed_config = version_detail.json()["raw_json"]
|
|
changed_config["entries"]["inline"][0]["to"][0]["email"] = "changed@example.org"
|
|
denied_recipient_edit = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=reader_headers,
|
|
json={"campaign_json": changed_config},
|
|
)
|
|
self.assertEqual(denied_recipient_edit.status_code, 403, denied_recipient_edit.text)
|
|
self.assertEqual(self.client.get(f"/api/v1/campaigns/{campaign_id}", headers=other_headers).status_code, 403)
|
|
|
|
def test_campaign_scoped_mail_profile_policy_is_enforced(self) -> None:
|
|
headers, _ = self._login()
|
|
created = self.client.post(
|
|
"/api/v1/campaigns/new",
|
|
headers=headers,
|
|
json={"external_id": "profile-policy-campaign", "name": "Profile policy campaign"},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
|
|
profile = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Campaign SMTP",
|
|
"scope_type": "campaign",
|
|
"scope_id": campaign_id,
|
|
"smtp": {"host": "allowed.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"},
|
|
},
|
|
)
|
|
self.assertEqual(profile.status_code, 201, profile.text)
|
|
profile_id = profile.json()["id"]
|
|
self.assertEqual(profile.json()["scope_type"], "campaign")
|
|
|
|
effective = self.client.get(f"/api/v1/mail/profiles?campaign_id={campaign_id}", headers=headers)
|
|
self.assertEqual(effective.status_code, 200, effective.text)
|
|
self.assertIn(profile_id, {item["id"] for item in effective.json()["profiles"]})
|
|
|
|
policy = self.client.put(
|
|
f"/api/v1/mail/policies/campaign?scope_id={campaign_id}",
|
|
headers=headers,
|
|
json={"policy": {"blacklist": {"smtp_hosts": ["blocked.smtp.local"]}}},
|
|
)
|
|
self.assertEqual(policy.status_code, 200, policy.text)
|
|
|
|
blocked = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Blocked SMTP",
|
|
"scope_type": "campaign",
|
|
"scope_id": campaign_id,
|
|
"smtp": {"host": "blocked.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"},
|
|
},
|
|
)
|
|
self.assertEqual(blocked.status_code, 422, blocked.text)
|
|
self.assertIn("blacklist", blocked.json()["detail"])
|
|
|
|
def test_allowed_mail_profile_ids_gate_campaign_selection(self) -> None:
|
|
headers, _ = self._login()
|
|
created = self.client.post(
|
|
"/api/v1/campaigns/new",
|
|
headers=headers,
|
|
json={"external_id": "profile-selection-policy", "name": "Profile selection policy"},
|
|
)
|
|
self.assertEqual(created.status_code, 200, created.text)
|
|
campaign_id = created.json()["campaign"]["id"]
|
|
version_id = created.json()["version"]["id"]
|
|
|
|
profile = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Tenant SMTP",
|
|
"smtp": {"host": "allowed.smtp.local", "port": 2525, "username": "sender@example.org", "password": "secret", "security": "starttls"},
|
|
},
|
|
)
|
|
self.assertEqual(profile.status_code, 201, profile.text)
|
|
profile_id = profile.json()["id"]
|
|
|
|
denied_policy = self.client.put(
|
|
f"/api/v1/mail/policies/campaign?scope_id={campaign_id}",
|
|
headers=headers,
|
|
json={"policy": {"allowed_profile_ids": ["not-the-profile"]}},
|
|
)
|
|
self.assertEqual(denied_policy.status_code, 200, denied_policy.text)
|
|
|
|
detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers)
|
|
self.assertEqual(detail.status_code, 200, detail.text)
|
|
raw_json = detail.json()["raw_json"]
|
|
raw_json["server"] = {"mail_profile_id": profile_id}
|
|
denied_update = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=headers,
|
|
json={"campaign_json": raw_json},
|
|
)
|
|
self.assertEqual(denied_update.status_code, 422, denied_update.text)
|
|
|
|
allowed_policy = self.client.put(
|
|
f"/api/v1/mail/policies/campaign?scope_id={campaign_id}",
|
|
headers=headers,
|
|
json={"policy": {"allowed_profile_ids": [profile_id]}},
|
|
)
|
|
self.assertEqual(allowed_policy.status_code, 200, allowed_policy.text)
|
|
allowed_update = self.client.put(
|
|
f"/api/v1/campaigns/{campaign_id}/versions/{version_id}",
|
|
headers=headers,
|
|
json={"campaign_json": raw_json},
|
|
)
|
|
self.assertEqual(allowed_update.status_code, 200, allowed_update.text)
|
|
|
|
def test_locked_mail_profile_credential_policy_blocks_campaign_override(self) -> None:
|
|
headers, _ = self._login()
|
|
profile = self.client.post(
|
|
"/api/v1/mail/profiles",
|
|
headers=headers,
|
|
json={
|
|
"name": "Locked credential SMTP",
|
|
"smtp": {
|
|
"host": "mock.smtp",
|
|
"port": 2525,
|
|
"username": "profile-smtp@example.org",
|
|
"password": "profile-smtp-secret",
|
|
"security": "starttls",
|
|
},
|
|
},
|
|
)
|
|
self.assertEqual(profile.status_code, 201, profile.text)
|
|
profile_id = profile.json()["id"]
|
|
|
|
policy = self.client.put(
|
|
"/api/v1/mail/policies/tenant",
|
|
headers=headers,
|
|
json={"policy": {"smtp_credentials": {"inherit": True, "allow_override": False}}},
|
|
)
|
|
self.assertEqual(policy.status_code, 200, policy.text)
|
|
self.assertTrue(policy.json()["effective_policy"] is None)
|
|
|
|
campaign_json = {
|
|
"version": "1.0",
|
|
"campaign": {"id": "locked-profile-creds", "name": "Locked profile creds", "mode": "test"},
|
|
"fields": [{"name": "first_name", "type": "string", "required": True}],
|
|
"global_values": {},
|
|
"server": {
|
|
"mail_profile_id": profile_id,
|
|
"inherit_smtp_credentials": False,
|
|
"smtp": {"username": "campaign-smtp@example.org", "password": "campaign-smtp-secret"},
|
|
},
|
|
"recipients": {
|
|
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
|
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
|
},
|
|
"template": {"subject": "Hello ${local::first_name}", "text": "Hello ${local::first_name}."},
|
|
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
|
|
"entries": {"inline": [{"id": "recipient-1", "fields": {"first_name": "Recipient"}}]},
|
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
|
"delivery": {"rate_limit": {"messages_per_minute": 60}, "retry": {"max_attempts": 3, "backoff_seconds": [1]}, "imap_append_sent": {"enabled": False}},
|
|
"status_tracking": {"enabled": True},
|
|
}
|
|
blocked = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(blocked.status_code, 422, blocked.text)
|
|
self.assertIn("locked", blocked.json()["detail"])
|
|
|
|
campaign_json["server"] = {"mail_profile_id": profile_id}
|
|
allowed = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
|
|
self.assertEqual(allowed.status_code, 200, allowed.text)
|
|
|
|
def test_file_and_raw_mail_permissions_are_independently_enforced(self) -> None:
|
|
owner_headers, _ = self._login()
|
|
role = self._create_role(
|
|
owner_headers,
|
|
slug="uploader-and-mail-tester",
|
|
permissions=["files:read", "files:upload", "mail_servers:test"],
|
|
)
|
|
self._create_user(
|
|
owner_headers,
|
|
email="uploader@example.local",
|
|
password="uploader-password",
|
|
role_ids=[str(role["id"])],
|
|
)
|
|
headers, login = self._login_as(email="uploader@example.local", password="uploader-password")
|
|
user_id = login["user"]["id"]
|
|
uploaded = self.client.post(
|
|
"/api/v1/files/upload",
|
|
headers=headers,
|
|
data={"owner_type": "user", "owner_id": user_id, "path": ""},
|
|
files=[("files", ("allowed.txt", b"allowed", "text/plain"))],
|
|
)
|
|
self.assertEqual(uploaded.status_code, 200, uploaded.text)
|
|
file_id = uploaded.json()["files"][0]["id"]
|
|
self.assertEqual(self.client.get(f"/api/v1/files/{file_id}/download", headers=headers).status_code, 403)
|
|
self.assertEqual(self.client.delete(f"/api/v1/files/{file_id}", headers=headers).status_code, 403)
|
|
|
|
raw_test = self.client.post(
|
|
"/api/v1/mail/test-smtp",
|
|
headers=headers,
|
|
json={"host": "mock.smtp", "port": 2525, "username": "u", "password": "p", "security": "starttls"},
|
|
)
|
|
self.assertEqual(raw_test.status_code, 403, raw_test.text)
|
|
self.assertIn("manage_credentials", raw_test.json()["detail"])
|
|
|
|
def test_profile_refresh_and_system_role_protection_model(self) -> None:
|
|
headers, _ = self._login()
|
|
profile = self.client.patch(
|
|
"/api/v1/auth/profile",
|
|
headers=headers,
|
|
json={"display_name": "Global Account Name", "tenant_display_name": "Tenant Alias"},
|
|
)
|
|
self.assertEqual(profile.status_code, 200, profile.text)
|
|
self.assertEqual(profile.json()["user"]["display_name"], "Global Account Name")
|
|
self.assertEqual(profile.json()["user"]["tenant_display_name"], "Tenant Alias")
|
|
refreshed = self.client.get("/api/v1/auth/me", headers=headers)
|
|
self.assertEqual(refreshed.status_code, 200, refreshed.text)
|
|
self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name")
|
|
self.assertEqual(refreshed.json()["user"]["tenant_display_name"], "Tenant Alias")
|
|
|
|
roles = self.client.get("/api/v1/admin/system/roles", headers=headers)
|
|
self.assertEqual(roles.status_code, 200, roles.text)
|
|
owner = next(item for item in roles.json()["roles"] if item["slug"] == "system_owner")
|
|
administrator = next(item for item in roles.json()["roles"] if item["slug"] == "system_admin")
|
|
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
|
|
self.assertTrue(owner["is_builtin"])
|
|
self.assertEqual(owner["user_assignments"], 1)
|
|
self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES))
|
|
self.assertEqual(owner["permissions"], ["system:*"])
|
|
self.assertFalse(administrator["is_builtin"])
|
|
self.assertFalse(auditor["is_builtin"])
|
|
|
|
blocked_owner_edit = self.client.patch(
|
|
f"/api/v1/admin/system/roles/{owner['id']}",
|
|
headers=headers,
|
|
json={"name": "Changed owner"},
|
|
)
|
|
self.assertEqual(blocked_owner_edit.status_code, 409, blocked_owner_edit.text)
|
|
edited_admin = self.client.patch(
|
|
f"/api/v1/admin/system/roles/{administrator['id']}",
|
|
headers=headers,
|
|
json={"name": "Platform administrator"},
|
|
)
|
|
self.assertEqual(edited_admin.status_code, 200, edited_admin.text)
|
|
self.assertEqual(edited_admin.json()["name"], "Platform administrator")
|
|
|
|
|
|
|
|
def test_admin_audit_supports_lazy_pagination_sorting_and_filters(self) -> None:
|
|
headers, _ = self._login()
|
|
for index in range(5):
|
|
updated = self.client.patch(
|
|
"/api/v1/admin/system/settings",
|
|
headers=headers,
|
|
json={
|
|
"default_locale": f"en-{index}",
|
|
"allow_tenant_custom_groups": True,
|
|
"allow_tenant_custom_roles": True,
|
|
"allow_tenant_api_keys": True,
|
|
},
|
|
)
|
|
self.assertEqual(updated.status_code, 200, updated.text)
|
|
|
|
first_page = self.client.get(
|
|
"/api/v1/admin/audit",
|
|
headers=headers,
|
|
params={
|
|
"scope": "system",
|
|
"page": 1,
|
|
"page_size": 2,
|
|
"sort_by": "action",
|
|
"sort_direction": "asc",
|
|
"filter_action": "system_settings",
|
|
},
|
|
)
|
|
self.assertEqual(first_page.status_code, 200, first_page.text)
|
|
payload = first_page.json()
|
|
self.assertEqual(payload["page"], 1)
|
|
self.assertEqual(payload["page_size"], 2)
|
|
self.assertGreaterEqual(payload["total"], 5)
|
|
self.assertEqual(len(payload["items"]), 2)
|
|
self.assertTrue(all("system_settings" in item["action"] for item in payload["items"]))
|
|
|
|
second_page = self.client.get(
|
|
"/api/v1/admin/audit",
|
|
headers=headers,
|
|
params={
|
|
"scope": "system",
|
|
"page": 2,
|
|
"page_size": 2,
|
|
"filter_actor": "admin@example.local",
|
|
},
|
|
)
|
|
self.assertEqual(second_page.status_code, 200, second_page.text)
|
|
self.assertEqual(second_page.json()["page"], 2)
|
|
self.assertEqual(len(second_page.json()["items"]), 2)
|
|
|
|
|
|
|
|
def test_tenant_owner_selection_audit_scope_and_last_owner_protection(self) -> None:
|
|
headers, _ = self._login()
|
|
created_account = self.client.post(
|
|
"/api/v1/admin/system/accounts",
|
|
headers=headers,
|
|
json={
|
|
"email": "selected-owner@example.local",
|
|
"display_name": "Selected Owner",
|
|
"password": "selected-owner-password",
|
|
"password_reset_required": False,
|
|
"is_active": True,
|
|
"role_ids": [],
|
|
"memberships": [],
|
|
},
|
|
)
|
|
self.assertEqual(created_account.status_code, 201, created_account.text)
|
|
account_id = created_account.json()["account"]["account_id"]
|
|
|
|
candidates = self.client.get("/api/v1/admin/tenants/owner-candidates", headers=headers)
|
|
self.assertEqual(candidates.status_code, 200, candidates.text)
|
|
self.assertIn(account_id, {item["account_id"] for item in candidates.json()["accounts"]})
|
|
|
|
created_tenant = self.client.post(
|
|
"/api/v1/admin/tenants",
|
|
headers=headers,
|
|
json={
|
|
"slug": "selected-owner",
|
|
"name": "Selected owner tenant",
|
|
"owner_account_id": account_id,
|
|
"settings": {},
|
|
},
|
|
)
|
|
self.assertEqual(created_tenant.status_code, 201, created_tenant.text)
|
|
tenant_id = created_tenant.json()["id"]
|
|
|
|
system_audit = self.client.get(
|
|
"/api/v1/admin/audit?scope=system&limit=500",
|
|
headers=headers,
|
|
)
|
|
self.assertEqual(system_audit.status_code, 200, system_audit.text)
|
|
tenant_created_rows = [
|
|
item for item in system_audit.json()["items"]
|
|
if item["action"] == "tenant.created" and item["object_id"] == tenant_id
|
|
]
|
|
self.assertEqual(len(tenant_created_rows), 1)
|
|
self.assertEqual(tenant_created_rows[0]["scope"], "system")
|
|
|
|
owner_headers, _ = self._login_as(
|
|
email="selected-owner@example.local",
|
|
password="selected-owner-password",
|
|
tenant_slug="selected-owner",
|
|
)
|
|
users = self.client.get("/api/v1/admin/users", headers=owner_headers)
|
|
self.assertEqual(users.status_code, 200, users.text)
|
|
owner = next(item for item in users.json()["users"] if item["account_id"] == account_id)
|
|
self.assertTrue(owner["is_owner"])
|
|
self.assertTrue(owner["is_last_active_owner"])
|
|
|
|
tenant_audit = self.client.get(
|
|
"/api/v1/admin/audit?scope=tenant&limit=500",
|
|
headers=owner_headers,
|
|
)
|
|
self.assertEqual(tenant_audit.status_code, 200, tenant_audit.text)
|
|
self.assertFalse(any(item["action"] == "tenant.created" for item in tenant_audit.json()["items"]))
|
|
self.assertTrue(all(item["scope"] == "tenant" for item in tenant_audit.json()["items"]))
|
|
|
|
blocked = self.client.patch(
|
|
f"/api/v1/admin/users/{owner['id']}",
|
|
headers=owner_headers,
|
|
json={"is_active": False},
|
|
)
|
|
self.assertEqual(blocked.status_code, 409, blocked.text)
|
|
|
|
accounts = self.client.get("/api/v1/admin/system/accounts", headers=headers)
|
|
self.assertEqual(accounts.status_code, 200, accounts.text)
|
|
selected = next(item for item in accounts.json()["accounts"] if item["account_id"] == account_id)
|
|
selected_membership = next(item for item in selected["memberships"] if item["tenant_id"] == tenant_id)
|
|
self.assertTrue(selected_membership["is_owner"])
|
|
self.assertTrue(selected_membership["is_last_active_owner"])
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|