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 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(router, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as full_report_denied, ): router.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(router, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as job_detail_denied, ): router.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(router, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as report_email_denied, ): router.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(router, "_get_campaign_for_principal", return_value=campaign), pytest.raises(HTTPException) as diagnostics_denied, ): router.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(router, "_get_campaign_for_principal") as acl, patch.object( router, "generate_aggregate_campaign_report", return_value=safe_projection, ) as generate, ): result = router.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 router.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()