383 lines
14 KiB
Python
383 lines
14 KiB
Python
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
|