feat(campaign): add privacy-safe aggregate reports

This commit is contained in:
2026-07-22 08:44:12 +02:00
parent 21f3014ac5
commit 06125cc0e8
5 changed files with 1043 additions and 4 deletions

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from govoplan_campaign.backend.report_privacy_policy import (
CampaignReportPrivacyPolicy,
CampaignReportPrivacyPolicyError,
DEFAULT_SMALL_CELL_THRESHOLD,
effective_campaign_report_privacy_policy,
)
from govoplan_campaign.backend.reports.aggregate import build_aggregate_campaign_report
class _PolicySession:
def __init__(self, settings: dict[str, object] | None = None) -> None:
self.tenant = SimpleNamespace(settings=settings or {})
def get(self, _model, _id):
return self.tenant
def _policy(threshold: int = 5) -> CampaignReportPrivacyPolicy:
return CampaignReportPrivacyPolicy(
small_cell_threshold=threshold,
source="test",
deployment_small_cell_threshold=threshold,
)
def _campaign() -> SimpleNamespace:
now = datetime(2026, 7, 22, 8, 0, tzinfo=UTC)
return SimpleNamespace(
id="campaign-safe",
tenant_id="tenant-safe",
external_id="must-not-leak-external-id",
name="Semester notification",
description="Business-safe description",
status="partially_completed",
owner_user_id="must-not-leak-owner",
current_version_id="must-not-leak-version-id",
updated_at=now,
)
def _version(*, inactive_count: int = 0) -> SimpleNamespace:
return SimpleNamespace(
id="must-not-leak-version-id",
version_number=7,
build_summary={"inactive_count": inactive_count, "build_token": "must-not-leak-token"},
execution_snapshot={"smtp": {"password": "must-not-leak-secret"}},
raw_json={"entries": [{"email": "must-not-leak@example.test"}]},
)
def _job(index: int, send_status: str, **overrides: object) -> SimpleNamespace:
started = datetime(2026, 7, 22, 8, 0, tzinfo=UTC) + timedelta(minutes=index)
values: dict[str, object] = {
"id": f"job-{index}",
"recipient_email": f"private-{index}@example.test",
"subject": f"Personal subject {index}",
"last_error": "smtp.internal.example provider-secret",
"resolved_attachments": [{"storage_key": f"private/{index}"}],
"send_status": send_status,
"validation_status": "ready",
"build_status": "built",
"queued_at": started,
"smtp_started_at": started,
"sent_at": started if send_status in {"smtp_accepted", "sent"} else None,
"outcome_unknown_at": started if send_status == "outcome_unknown" else None,
}
values.update(overrides)
return SimpleNamespace(**values)
def test_aggregate_projection_has_a_strict_recipient_free_shape() -> None:
jobs = [
*[_job(index, "smtp_accepted") for index in range(10)],
*[_job(index + 10, "failed_permanent") for index in range(5)],
]
payload = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(inactive_count=5), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(),
generated_at=datetime(2026, 7, 22, 12, 0, tzinfo=UTC),
).model_dump(mode="json")
assert set(payload) == {
"generated_at",
"campaign",
"version_number",
"completion_state",
"population",
"outcomes",
"time_range",
"privacy",
}
assert set(payload["campaign"]) == {"id", "name", "status"}
assert payload["population"]["denominator"] == {"value": 15, "suppressed": False}
assert payload["outcomes"]["smtp_accepted"] == {"value": 10, "suppressed": False}
assert payload["outcomes"]["failed"] == {"value": 5, "suppressed": False}
assert payload["completion_state"] == "partially_completed"
serialized = repr(payload)
for forbidden in (
"private-0@example.test",
"Personal subject",
"smtp.internal.example",
"provider-secret",
"storage_key",
"must-not-leak",
"Business-safe description",
"job-0",
):
assert forbidden not in serialized
def test_small_cells_use_primary_and_complementary_suppression() -> None:
jobs = [
*[_job(index, "smtp_accepted") for index in range(8)],
_job(8, "failed_permanent"),
]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(inactive_count=1), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.population.denominator.value == 9
assert report.outcomes.failed.suppressed is True
assert report.outcomes.failed.value is None
assert report.outcomes.smtp_accepted.suppressed is True
assert report.outcomes.smtp_accepted.value is None
assert report.population.inactive_source_entries.suppressed is True
assert report.time_range.suppressed is True
assert report.privacy.suppression_applied is True
def test_all_small_cells_also_suppress_the_denominator_and_state() -> None:
jobs = [_job(0, "smtp_accepted"), _job(1, "failed_permanent")]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.population.denominator.model_dump() == {"value": None, "suppressed": True}
assert report.completion_state == "suppressed"
assert report.outcomes.smtp_accepted.value is None
assert report.outcomes.failed.value is None
def test_explicitly_skipped_jobs_are_exclusions_not_unattempted_outcomes() -> None:
jobs = [_job(index, "skipped") for index in range(5)]
report = build_aggregate_campaign_report(
campaign=_campaign(), # type: ignore[arg-type]
version=_version(), # type: ignore[arg-type]
jobs=jobs, # type: ignore[arg-type]
policy=_policy(5),
)
assert report.outcomes.excluded.value == 5
assert report.outcomes.not_attempted.value == 0
assert report.completion_state == "not_started"
def test_report_privacy_policy_defaults_to_five_and_tenant_can_only_strengthen() -> None:
default = effective_campaign_report_privacy_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={},
)
assert default.small_cell_threshold == DEFAULT_SMALL_CELL_THRESHOLD == 5
assert default.source == "deployment_default"
strengthened = effective_campaign_report_privacy_policy(
_PolicySession(
{"campaign_report_privacy_policy": {"small_cell_threshold": 10}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "5"},
)
assert strengthened.small_cell_threshold == 10
assert strengthened.source == "tenant"
floor = effective_campaign_report_privacy_policy(
_PolicySession(
{"campaign_report_privacy_policy": {"small_cell_threshold": 3}}
), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "7"},
)
assert floor.small_cell_threshold == 7
assert floor.source == "deployment_floor"
@pytest.mark.parametrize("value", [True, 0, 1, 101, "2.5", "disabled"])
def test_invalid_report_privacy_policy_fails_closed(value: object) -> None:
with pytest.raises(CampaignReportPrivacyPolicyError):
effective_campaign_report_privacy_policy(
_PolicySession(), # type: ignore[arg-type]
tenant_id="tenant-1",
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": value}, # type: ignore[dict-item]
)

View File

@@ -0,0 +1,186 @@
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,
)
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()