from __future__ import annotations import importlib from types import SimpleNamespace from unittest.mock import patch import pytest from alembic.migration import MigrationContext from alembic.operations import Operations from fastapi import HTTPException from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import Session, sessionmaker from govoplan_access.backend.db.models import Account, Group, User from govoplan_campaign.backend.db.models import ( Campaign, CampaignCollaborationEntry, CampaignJob, CampaignShare, CampaignVersion, ) from govoplan_campaign.backend.routes.collaboration import ( create_campaign_collaboration_entry, list_campaign_collaboration, redact_campaign_collaboration_entry, withdraw_campaign_collaboration_entry, ) from govoplan_campaign.backend.schemas import ( CampaignCollaborationCreateRequest, CampaignCollaborationModerationRequest, CampaignCollaborationReferenceInput, ) from govoplan_core.core.access import GroupRef, UserRef from govoplan_core.core.change_sequence import ChangeSequenceEntry from govoplan_core.db.base import Base TENANT_ID = "tenant-1" class _Principal: tenant_id = TENANT_ID api_key = None def __init__(self, user_id: str, *scopes: str) -> None: self.user = SimpleNamespace( id=user_id, display_name=f"User {user_id}", email=f"{user_id}@example.test", ) self.scopes = frozenset(scopes) def has(self, scope: str) -> bool: return scope in self.scopes or "tenant:*" in self.scopes class _Directory: users = ( UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"), UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"), UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"), ) def users_for_tenant(self, tenant_id: str): return self.users if tenant_id == TENANT_ID else () def groups_for_user(self, user_id: str, *, tenant_id: str): if tenant_id == TENANT_ID and user_id == "user-2": return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Collaborators"),) return () class _Notifications: def __init__(self) -> None: self.requests = [] def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True): self.requests.append((request, enqueue_delivery)) return {"id": f"notification-{len(self.requests)}"} class _UnavailableNotifications: def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True): raise RuntimeError("Notifications is temporarily unavailable") @pytest.fixture() def session() -> Session: engine = create_engine("sqlite+pysqlite:///:memory:") Base.metadata.create_all( engine, tables=[ Account.__table__, User.__table__, Group.__table__, ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__, CampaignJob.__table__, CampaignShare.__table__, CampaignCollaborationEntry.__table__, ], ) session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) database = session_factory() database.add_all( [ Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test") for number in range(1, 4) ] ) database.flush() database.add_all( [ User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test") for number in range(1, 4) ] ) database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="collaborators", name="Collaborators")) campaign = Campaign( id="campaign-1", tenant_id=TENANT_ID, owner_user_id="user-1", external_id="campaign-1", name="Campaign One", current_version_id="version-1", ) version = CampaignVersion( id="version-1", campaign_id=campaign.id, version_number=1, raw_json={ "version": "1.0", "entries": {"imports": [{"id": "import-1", "source_type": "csv"}]}, "attachments": {"global": [{"label": "Notice"}]}, }, ) database.add_all( [ campaign, version, CampaignShare( id="share-1", tenant_id=TENANT_ID, campaign_id=campaign.id, target_type="group", target_id="group-1", permission="read", ), CampaignJob( id="job-1", tenant_id=TENANT_ID, campaign_id=campaign.id, campaign_version_id=version.id, entry_index=0, ), ] ) database.commit() try: yield database finally: database.close() engine.dispose() def _commit_audit(session: Session, *_args, **_kwargs) -> None: session.commit() def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal: scopes = [ "campaigns:campaign:read", "campaigns:discussion:read", "campaigns:discussion:post", ] if moderate: scopes.append("campaigns:discussion:moderate") return _Principal(user_id, *scopes) def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None: notifications = _Notifications() original_version = dict(session.get(CampaignVersion, "version-1").raw_json) with ( patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()), patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications), patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit, ): response = create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest( content="Please review the frozen version.", mention_user_ids=["user-2", "user-1", "user-2"], reference=CampaignCollaborationReferenceInput( kind="campaign_version", id="version-1", ), ), session, _principal(), ) assert response.content == "Please review the frozen version." assert response.reference is not None assert response.reference.label == "Version 1" assert response.mention_user_ids == ["user-2"] assert session.get(CampaignVersion, "version-1").raw_json == original_version assert len(notifications.requests) == 1 notification, enqueue_delivery = notifications.requests[0] assert notification.recipient_id == "user-2" assert notification.payload["content_disclosed"] is False assert enqueue_delivery is False details = audit.call_args.kwargs["details"] assert details["content_disclosed"] is False assert "Please review" not in repr(details) def test_mentions_reject_users_without_campaign_access(session: Session) -> None: with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()): with pytest.raises(HTTPException) as raised: create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]), session, _principal(), ) assert raised.value.status_code == 422 assert session.query(CampaignCollaborationEntry).count() == 0 def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None: with ( patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()), patch( "govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=_UnavailableNotifications(), ), patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit), ): response = create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest( content="This discussion entry must survive an optional integration outage.", mention_user_ids=["user-2"], ), session, _principal(), ) stored = session.get(CampaignCollaborationEntry, response.id) assert stored is not None assert stored.content == response.content assert stored.mention_user_ids == ["user-2"] def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None: migration = importlib.import_module( "govoplan_campaign.backend.migrations.versions." "c7d8e9f0a1b2_v0120_campaign_collaboration" ) engine = create_engine("sqlite+pysqlite:///:memory:") with engine.begin() as connection: connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)")) connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)")) connection.execute( text( "CREATE TABLE campaign_versions (" "id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)" ) ) context = MigrationContext.configure(connection) with patch.object(migration, "op", Operations(context)): migration.upgrade() migration.upgrade() inspector = inspect(connection) columns = { column["name"] for column in inspector.get_columns("campaign_collaboration_entries") } indexes = { index["name"] for index in inspector.get_indexes("campaign_collaboration_entries") } assert { "campaign_id", "campaign_version_id", "content_sha256", "mention_user_ids", "withdrawn_at", "redacted_at", }.issubset(columns) assert "ix_campaign_collaboration_entries_thread" in indexes with patch.object(migration, "op", Operations(context)): migration.downgrade() assert not inspect(connection).has_table("campaign_collaboration_entries") def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None: with ( patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None), patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit), ): public_entry = create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest(content="Visible discussion"), session, _principal(), ) moderator_entry = create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"), session, _principal(moderate=True), ) ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal()) moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True)) withdrawn = withdraw_campaign_collaboration_entry( "campaign-1", public_entry.id, CampaignCollaborationModerationRequest(reason="Posted in error"), session, _principal(), ) redacted = redact_campaign_collaboration_entry( "campaign-1", moderator_entry.id, CampaignCollaborationModerationRequest(reason="Contains restricted material"), session, _principal(moderate=True), ) assert [entry.id for entry in ordinary.items] == [public_entry.id] assert ordinary.has_more is False assert [entry.id for entry in moderated.items] == [moderator_entry.id] assert moderated.has_more is True assert moderated.next_cursor second_page = list_campaign_collaboration( "campaign-1", 1, moderated.next_cursor, session, _principal(moderate=True), ) assert [entry.id for entry in second_page.items] == [public_entry.id] assert withdrawn.tombstone == "withdrawn" assert withdrawn.content is None assert redacted.tombstone == "redacted" assert redacted.content is None assert session.get(CampaignCollaborationEntry, public_entry.id).content is None assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256 @pytest.mark.parametrize( ("kind", "reference_id"), [ ("recipient_import_batch", "version-1:import-1"), ("attachment_rule", "version-1:attachments.global[0]"), ("delivery_job", "job-1"), ("report", "campaign-1:version-1:delivery"), ], ) def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None: with ( patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None), patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit), ): response = create_campaign_collaboration_entry( "campaign-1", CampaignCollaborationCreateRequest( content="Reference context", reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type] ), session, _principal(), ) assert response.reference is not None assert response.reference.kind == kind assert response.reference.id == reference_id