203 lines
7.1 KiB
Python
203 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_access.backend.db.models import Account, Group, User
|
|
from govoplan_campaign.backend import router as campaign_api
|
|
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
|
from govoplan_campaign.backend.routes import jobs as job_routes
|
|
from govoplan_campaign.backend.routes import reports as report_routes
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
|
from govoplan_campaign.backend.reports.aggregate import (
|
|
AggregateCampaignReportError,
|
|
generate_aggregate_campaign_report,
|
|
)
|
|
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
|
|
|
|
|
class _Principal:
|
|
def __init__(self, *scopes: str, tenant_id: str = "tenant-1") -> None:
|
|
self.scopes = set(scopes)
|
|
self.tenant_id = tenant_id
|
|
self.user = SimpleNamespace(id="reader-1")
|
|
|
|
def has(self, scope: str) -> bool:
|
|
return scope in self.scopes
|
|
|
|
|
|
def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|
principal = _Principal("campaigns:report:read")
|
|
session = Mock()
|
|
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
|
|
|
with (
|
|
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
|
pytest.raises(HTTPException) as full_report_denied,
|
|
):
|
|
report_routes.campaign_report(
|
|
"campaign-1",
|
|
session=session,
|
|
principal=principal, # type: ignore[arg-type]
|
|
)
|
|
assert full_report_denied.value.status_code == 403
|
|
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
|
|
|
with (
|
|
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
|
pytest.raises(HTTPException) as job_detail_denied,
|
|
):
|
|
job_routes.get_job_detail(
|
|
"campaign-1",
|
|
"job-1",
|
|
session=session,
|
|
principal=principal, # type: ignore[arg-type]
|
|
)
|
|
assert job_detail_denied.value.status_code == 403
|
|
|
|
with (
|
|
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
|
pytest.raises(HTTPException) as report_email_denied,
|
|
):
|
|
report_routes.email_campaign_report(
|
|
"campaign-1",
|
|
ReportEmailRequest(to=["auditor@example.test"]),
|
|
session=session,
|
|
principal=_Principal("campaigns:report:send"), # type: ignore[arg-type]
|
|
)
|
|
assert report_email_denied.value.status_code == 403
|
|
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
|
|
|
with (
|
|
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
|
pytest.raises(HTTPException) as diagnostics_denied,
|
|
):
|
|
job_routes.get_job_diagnostics(
|
|
"campaign-1",
|
|
"job-1",
|
|
session=session,
|
|
principal=_Principal("campaigns:diagnostic:read"), # type: ignore[arg-type]
|
|
)
|
|
assert diagnostics_denied.value.status_code == 403
|
|
assert "campaigns:recipient:read" in diagnostics_denied.value.detail
|
|
|
|
|
|
def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
|
principal = _Principal("campaigns:report:read")
|
|
session = Mock()
|
|
safe_projection = Mock()
|
|
|
|
with (
|
|
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
|
|
patch.object(
|
|
campaign_routes,
|
|
"generate_aggregate_campaign_report",
|
|
return_value=safe_projection,
|
|
) as generate,
|
|
):
|
|
result = campaign_routes.aggregate_campaign_report(
|
|
"campaign-1",
|
|
session=session,
|
|
principal=principal, # type: ignore[arg-type]
|
|
)
|
|
|
|
assert result is safe_projection
|
|
acl.assert_called_once_with(session, "campaign-1", principal)
|
|
generate.assert_called_once_with(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
version_id=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
|
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
|
route = next(item for item in campaign_api.router.routes if item.path == path)
|
|
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
|
|
|
with pytest.raises(HTTPException) as denied:
|
|
dependency.call(_Principal())
|
|
assert denied.value.status_code == 403
|
|
|
|
principal = _Principal("campaigns:report:read")
|
|
assert dependency.call(principal) is principal
|
|
|
|
|
|
def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() -> None:
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
create_scope_tables(engine)
|
|
Base.metadata.create_all(
|
|
engine,
|
|
tables=[
|
|
Account.__table__,
|
|
User.__table__,
|
|
Group.__table__,
|
|
Campaign.__table__,
|
|
CampaignVersion.__table__,
|
|
CampaignJob.__table__,
|
|
ChangeSequenceEntry.__table__,
|
|
],
|
|
)
|
|
with Session(engine) as session:
|
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
|
|
campaign = Campaign(
|
|
id="campaign-1",
|
|
tenant_id="tenant-1",
|
|
external_id="external-1",
|
|
name="Safe aggregate",
|
|
description=None,
|
|
status="sent",
|
|
)
|
|
version = CampaignVersion(
|
|
id="version-1",
|
|
campaign_id=campaign.id,
|
|
version_number=1,
|
|
raw_json={"mail": {"profile_id": "optional-module-not-loaded"}},
|
|
schema_version="5",
|
|
build_summary={},
|
|
)
|
|
campaign.current_version_id = version.id
|
|
session.add_all([campaign, version])
|
|
for index in range(5):
|
|
session.add(CampaignJob(
|
|
id=f"job-{index}",
|
|
tenant_id="tenant-1",
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
entry_index=index,
|
|
recipient_email=f"private-{index}@example.test",
|
|
subject="Private",
|
|
build_status="built",
|
|
validation_status="ready",
|
|
queue_status="queued",
|
|
send_status="smtp_accepted",
|
|
imap_status="not_requested",
|
|
))
|
|
session.commit()
|
|
|
|
report = generate_aggregate_campaign_report(
|
|
session,
|
|
tenant_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
)
|
|
assert report.population.denominator.value == 5
|
|
assert report.outcomes.smtp_accepted.value == 5
|
|
|
|
with pytest.raises(AggregateCampaignReportError):
|
|
generate_aggregate_campaign_report(
|
|
session,
|
|
tenant_id="tenant-2",
|
|
campaign_id="campaign-1",
|
|
)
|
|
|
|
engine.dispose()
|