from __future__ import annotations import copy from types import SimpleNamespace from unittest.mock import patch import pytest from fastapi import HTTPException from sqlalchemy import Column, String, Table, create_engine from sqlalchemy.orm import Session, sessionmaker from govoplan_campaign.backend.campaign.transfers import ( DEFAULT_PORTABLE_CAMPAIGN_SCOPES, build_campaign_portable_package, canonical_sha256, inspect_campaign_portable_package, ) from govoplan_campaign.backend.db.models import ( Campaign, CampaignIssue, CampaignJob, CampaignShare, CampaignVersion, ) from govoplan_campaign.backend.persistence.versions import minimal_campaign_json from govoplan_campaign.backend.routes.transfers import ( export_campaign_package, import_campaign_package, preview_campaign_import, ) from govoplan_campaign.backend.schemas import ( CampaignExportRequest, CampaignImportApplyRequest, CampaignImportPreviewRequest, CampaignPortablePackageResponse, ) from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base def _source() -> tuple[Campaign, CampaignVersion]: raw_json = minimal_campaign_json( external_id="monthly-notice", name="Monthly notice", description="Portable source", ) raw_json["fields"] = [ {"name": "case_id", "type": "string"}, {"name": "private_code", "type": "password"}, ] raw_json["global_values"] = { "office": "Permits", "private_code": "must-not-leave-the-source", } raw_json["server"] = { "mail_profile_id": "mail-profile-source", "smtp_server_id": "smtp-source", "smtp_credential_id": "credential-source", } raw_json["template"] = { "subject": "Case {{case_id}}", "text": "Hello", "html": None, } raw_json["attachments"]["global"] = [ {"base_dir": ".", "file_filter": "notice.pdf", "required": True} ] raw_json["entries"]["inline"] = [ { "id": "recipient-1", "to": [{"email": "person@example.test"}], "fields": { "case_id": "A-1", "private_code": "recipient-secret", }, "attachments": [ {"base_dir": ".", "file_filter": "A-1.pdf", "required": True} ], } ] campaign = Campaign( id="campaign-source", tenant_id="tenant-source", external_id="monthly-notice", name="Monthly notice", description="Portable source", status="completed", settings={ "retention_days": 90, "provider_token": "must-not-export", }, mail_profile_policy={ "profile_id": "mail-profile-source", "credential_id": "credential-source", }, ) version = CampaignVersion( id="version-source", campaign_id=campaign.id, version_number=4, raw_json=raw_json, schema_version="1.0", workflow_state="completed", validation_summary={"ok": True, "error_count": 0}, build_summary={"built_count": 1}, editor_state={ "review_send": { "inspection_complete": True, "reviewed_message_keys": ["message-1"], "issue_decisions": [ { "decision": "accept", "issue_codes": ["attachment_warning"], "issue_fingerprint": "fingerprint-1", "message_sha256": "a" * 64, "reason": "Verified manually", } ], } }, ) return campaign, version def _job(campaign: Campaign, version: CampaignVersion) -> CampaignJob: return CampaignJob( id="job-1", tenant_id=campaign.tenant_id, campaign_id=campaign.id, campaign_version_id=version.id, entry_index=0, entry_id="recipient-1", recipient_email="person@example.test", message_id_header="", eml_sha256="b" * 64, build_status="built", validation_status="ready", queue_status="cancelled", send_status="smtp_accepted", postbox_status="not_requested", print_status="not_requested", imap_status="appended", attempt_count=1, delivery_provenance={"route": "mail", "storage_key": "hidden"}, ) def test_privacy_default_export_is_configuration_only_and_redacts_secrets() -> None: campaign, version = _source() package = build_campaign_portable_package( campaign=campaign, version=version, scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, module_version="0.1.24", ) assert package["scopes"] == ["metadata", "template_config"] assert set(package["payload"]) == {"metadata", "template_config"} assert package["manifest"]["secrets_included"] is False assert package["manifest"]["redactions"] == { "deployment_credential_reference": 1, "password_field_value": 2, "sensitive_setting": 2, } template = package["payload"]["template_config"] assert "private_code" not in template["configuration"]["global_values"] assert "smtp_credential_id" not in template["configuration"]["server"] assert "provider_token" not in template["campaign_settings"] assert "credential_id" not in template["mail_profile_policy"] serialized = CampaignPortablePackageResponse.model_validate(package).model_dump( mode="json" ) assert inspect_campaign_portable_package( serialized, selected_scopes=None, external_id="serialized-import", name="Serialized import", ).preview["compatible"] is True def test_full_export_import_applies_configuration_but_never_replays_evidence() -> None: campaign, version = _source() package = build_campaign_portable_package( campaign=campaign, version=version, scopes=( "metadata", "template_config", "recipients", "attachments", "review_state", "delivery_history", ), jobs=(_job(campaign, version),), issues=( CampaignIssue( id="issue-1", tenant_id=campaign.tenant_id, campaign_id=campaign.id, campaign_version_id=version.id, severity="warning", code="attachment_warning", message="Review attachment", ), ), module_version="0.1.24", ) inspection = inspect_campaign_portable_package( package, selected_scopes=None, external_id="monthly-notice-import", name="Imported monthly notice", ) assert inspection.preview["compatible"] is True assert inspection.configuration is not None assert inspection.configuration["campaign"] == { "id": "monthly-notice-import", "name": "Imported monthly notice", "description": "Portable source", "mode": "draft", } assert inspection.configuration["server"] == {} assert inspection.configuration["entries"]["inline"][0]["to"] == [ {"email": "person@example.test"} ] assert inspection.configuration["entries"]["inline"][0]["attachments"][0][ "file_filter" ] == "A-1.pdf" assert "private_code" not in inspection.configuration["entries"]["inline"][0][ "fields" ] skipped_codes = {item["code"] for item in inspection.preview["will_skip"]} assert skipped_codes == { "deployment_bound_mail_profile", "operational_evidence_not_replayed", } assert package["payload"]["review_state"]["decision_count"] == 1 assert package["payload"]["delivery_history"]["jobs"][0][ "recipient_email" ] == "person@example.test" assert "storage_key" not in package["payload"]["delivery_history"]["jobs"][0][ "delivery_provenance" ] def test_import_preview_reports_unselected_recipient_attachment_rules() -> None: campaign, version = _source() package = build_campaign_portable_package( campaign=campaign, version=version, scopes=("metadata", "attachments", "recipients"), module_version="0.1.24", ) inspection = inspect_campaign_portable_package( package, selected_scopes=("metadata", "attachments"), external_id="attachment-import", name="Attachment import", ) assert inspection.preview["compatible"] is True skipped = {item["code"]: item for item in inspection.preview["will_skip"]} assert skipped["recipient_scope_required"]["item_count"] == 1 assert skipped["scope_not_selected"]["scope"] == "recipients" assert inspection.configuration is not None assert inspection.configuration["entries"]["inline"] == [] def test_import_preview_fails_closed_when_package_is_tampered() -> None: campaign, version = _source() package = build_campaign_portable_package( campaign=campaign, version=version, scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, module_version="0.1.24", ) tampered = copy.deepcopy(package) tampered["payload"]["metadata"]["name"] = "Tampered" inspection = inspect_campaign_portable_package( tampered, selected_scopes=None, external_id="tampered-import", name="Tampered", ) assert inspection.preview["compatible"] is False assert inspection.configuration is None assert any("integrity checksum" in error for error in inspection.preview["errors"]) def test_import_preview_rejects_unsupported_campaign_schema_even_with_valid_checksum() -> None: campaign, version = _source() package = build_campaign_portable_package( campaign=campaign, version=version, scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, module_version="0.1.24", ) package["source"]["campaign_schema_version"] = "2.0" package["integrity"]["package_sha256"] = canonical_package_hash(package) inspection = inspect_campaign_portable_package( package, selected_scopes=None, external_id="future-import", name="Future import", ) assert inspection.preview["compatible"] is False assert any("schema version" in error for error in inspection.preview["errors"]) def canonical_package_hash(package: dict[str, object]) -> str: content = copy.deepcopy(package) content.pop("integrity", None) return canonical_sha256(content) class _Principal: tenant_id = "tenant-1" api_key = None def __init__(self, *scopes: str) -> None: self.user = SimpleNamespace(id="user-1", display_name="Importer") self.scopes = frozenset(scopes) def has(self, scope: str) -> bool: return scope in self.scopes or "tenant:*" in self.scopes @pytest.fixture() def route_session() -> Session: engine = create_engine("sqlite+pysqlite:///:memory:") access_users = Base.metadata.tables.get("access_users") if access_users is None: access_users = Table( "access_users", Base.metadata, Column("id", String(36), primary_key=True), ) access_groups = Base.metadata.tables.get("access_groups") if access_groups is None: access_groups = Table( "access_groups", Base.metadata, Column("id", String(36), primary_key=True), ) Base.metadata.create_all( engine, tables=[ access_users, access_groups, Campaign.__table__, CampaignVersion.__table__, CampaignShare.__table__, CampaignJob.__table__, CampaignIssue.__table__, ChangeSequenceEntry.__table__, ], ) session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) database = session_factory() user_values = {"id": "user-1"} if "tenant_id" in access_users.c: user_values.update( tenant_id="tenant-1", account_id="account-1", email="user-1@example.test", ) database.execute(access_users.insert().values(**user_values)) raw_json = minimal_campaign_json(external_id="source", name="Source") raw_json["entries"]["inline"] = [ {"id": "one", "to": [{"email": "one@example.test"}]} ] source = Campaign( id="source-campaign", tenant_id="tenant-1", created_by_user_id="user-1", owner_user_id="user-1", external_id="source", name="Source", status="draft", current_version_id="source-version", ) source_version = CampaignVersion( id="source-version", campaign_id=source.id, version_number=1, raw_json=raw_json, ) database.add_all((source, source_version)) database.commit() try: yield database finally: database.close() engine.dispose() def test_export_route_enforces_recipient_export_scope(route_session: Session) -> None: principal = _Principal( "campaigns:campaign:read", "campaigns:campaign:export", "campaigns:recipient:read", ) with pytest.raises(HTTPException) as denied: export_campaign_package( "source-campaign", "source-version", CampaignExportRequest(scopes=["metadata", "recipients"]), session=route_session, principal=principal, ) assert denied.value.status_code == 403 assert denied.value.detail == "Missing scope: campaigns:recipient:export" def test_export_preview_and_apply_routes_keep_matching_provenance( route_session: Session, ) -> None: exporter = _Principal( "campaigns:campaign:read", "campaigns:campaign:export", "campaigns:recipient:read", "campaigns:recipient:export", ) def commit_audit(active_session: Session, *_args, **_kwargs) -> None: active_session.commit() with patch( "govoplan_campaign.backend.routes.transfers.audit_from_principal", side_effect=commit_audit, ): package = export_campaign_package( "source-campaign", "source-version", CampaignExportRequest(scopes=["metadata", "template_config", "recipients"]), session=route_session, principal=exporter, ) limited_importer = _Principal( "campaigns:campaign:create", "campaigns:campaign:import", ) limited_preview = preview_campaign_import( CampaignImportPreviewRequest(package=package), session=route_session, principal=limited_importer, ) assert limited_preview["compatible"] is True assert "recipients" in limited_preview["selected_scopes"] importer = _Principal( "campaigns:campaign:create", "campaigns:campaign:import", "campaigns:recipient:write", "campaigns:recipient:import", ) preview = preview_campaign_import( CampaignImportPreviewRequest(package=package), session=route_session, principal=importer, ) assert preview["compatible"] is True assert preview["destination"]["external_id"] == "source-import" def create_import(active_session: Session, **kwargs): raw_json = kwargs["raw_json"] destination = Campaign( id="imported-campaign", tenant_id="tenant-1", created_by_user_id="user-1", owner_user_id="user-1", external_id=raw_json["campaign"]["id"], name=raw_json["campaign"]["name"], status="draft", current_version_id="imported-version", ) version = CampaignVersion( id="imported-version", campaign_id=destination.id, version_number=1, raw_json=raw_json, ) active_session.add_all((destination, version)) active_session.flush() return destination, version with ( patch( "govoplan_campaign.backend.routes.transfers.create_campaign_version_from_json", side_effect=create_import, ), patch( "govoplan_campaign.backend.routes.transfers.audit_from_principal", side_effect=commit_audit, ), patch( "govoplan_campaign.backend.routes.transfers._write_current_version_snapshot_if_available" ), ): response = import_campaign_package( CampaignImportApplyRequest( package=package, selected_scopes=preview["selected_scopes"], external_id=preview["destination"]["external_id"], name=preview["destination"]["name"], expected_package_sha256=preview["package_sha256"], ), session=route_session, principal=importer, ) assert response.campaign.external_id == "source-import" assert response.receipt["package_id"] == package["package_id"] assert response.receipt["package_sha256"] == package["integrity"]["package_sha256"] imported = route_session.get(Campaign, "imported-campaign") assert imported is not None assert imported.settings["portable_import"]["package_id"] == package["package_id"] assert route_session.query(CampaignJob).filter_by(campaign_id=imported.id).count() == 0