feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
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
|
||||
Reference in New Issue
Block a user