Files
govoplan-core/tests/test_core_events.py
Albrecht Degering c61abe154c
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled
Add module event producer coverage
2026-07-10 18:40:38 +02:00

258 lines
12 KiB
Python

from __future__ import annotations
import shutil
import tempfile
import unittest
from pathlib import Path
from fastapi import APIRouter
from fastapi.testclient import TestClient
from govoplan_core.audit.logging import audit_event, audit_operation_context
from govoplan_core.core.events import (
EventActorRef,
EventBus,
EventObjectRef,
EventTenantRef,
PlatformEvent,
current_event_trace,
event_bus_context,
event_context,
normalize_trace_id,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.base import Base
from govoplan_core.server.fastapi import create_govoplan_app
from tests.db_isolation import temporary_database
class CoreEventTests(unittest.TestCase):
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus()
seen: list[PlatformEvent] = []
def parent_handler(event: PlatformEvent) -> None:
seen.append(event)
bus.publish(PlatformEvent(type="child.ready", module_id="demo"))
bus.subscribe("parent.ready", parent_handler)
bus.subscribe("child.ready", seen.append)
bus.publish(PlatformEvent(type="parent.ready", module_id="demo", correlation_id="corr-1"))
self.assertEqual(2, len(seen))
parent, child = seen
self.assertEqual("corr-1", parent.correlation_id)
self.assertIsNone(parent.causation_id)
self.assertEqual("corr-1", child.correlation_id)
self.assertEqual(parent.event_id, child.causation_id)
def test_platform_event_serializes_governed_envelope_fields(self) -> None:
event = PlatformEvent(
type="case.permit.updated",
module_id="cases",
payload={"status": "approved"},
event_id="event-1",
correlation_id="corr-1",
causation_id="cause-1",
actor=EventActorRef(type="user", id="user-1", label="Case worker"),
tenant=EventTenantRef(id="tenant-1", slug="city", label="City Office"),
subject=EventObjectRef(type="person", id="person-1", label="Applicant"),
resource=EventObjectRef(type="permit", id="permit-1", label="Permit"),
classification="confidential",
)
payload = event.to_dict()
self.assertEqual("case.permit.updated", payload["type"])
self.assertEqual("cases", payload["module_id"])
self.assertEqual({"status": "approved"}, payload["payload"])
self.assertEqual("event-1", payload["event_id"])
self.assertEqual("corr-1", payload["correlation_id"])
self.assertEqual("cause-1", payload["causation_id"])
self.assertEqual({"type": "user", "id": "user-1", "label": "Case worker"}, payload["actor"])
self.assertEqual({"id": "tenant-1", "slug": "city", "label": "City Office"}, payload["tenant"])
self.assertEqual({"type": "person", "id": "person-1", "label": "Applicant"}, payload["subject"])
self.assertEqual({"type": "permit", "id": "permit-1", "label": "Permit"}, payload["resource"])
self.assertEqual("confidential", payload["classification"])
def test_event_bus_preserves_envelope_fields_when_adding_trace(self) -> None:
bus = EventBus()
seen: list[PlatformEvent] = []
event = PlatformEvent(
type="files.file.uploaded",
module_id="files",
actor=EventActorRef(type="api_key", id="key-1"),
tenant=EventTenantRef(id="tenant-1"),
subject=EventObjectRef(type="file", id="file-1"),
resource=EventObjectRef(type="folder", id="folder-1"),
classification="restricted",
)
bus.subscribe("files.file.uploaded", seen.append)
bus.publish(event)
self.assertEqual(1, len(seen))
traced = seen[0]
self.assertIsNotNone(traced.correlation_id)
self.assertEqual(event.actor, traced.actor)
self.assertEqual(event.tenant, traced.tenant)
self.assertEqual(event.subject, traced.subject)
self.assertEqual(event.resource, traced.resource)
self.assertEqual("restricted", traced.classification)
def test_request_correlation_middleware_sets_event_context_and_response_header(self) -> None:
router = APIRouter()
@router.get("/trace")
def trace() -> dict[str, str | None]:
active = current_event_trace()
return {"correlation_id": active.correlation_id if active else None}
app = create_govoplan_app(
title="event test",
version="test",
registry=PlatformRegistry(),
api_router=router,
)
with TestClient(app) as client:
response = client.get("/trace", headers={"X-Request-ID": "request-123"})
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("request-123", response.json()["correlation_id"])
self.assertEqual("request-123", response.headers["X-Correlation-ID"])
def test_audit_event_persists_trace_details_from_event_context(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-trace-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
with event_context(correlation_id="corr-1", causation_id="cause-1"):
item = audit_event(
session,
tenant_id=None,
scope="system",
action="demo.changed",
object_type="demo",
object_id="demo-1",
details={"password": "secret"},
)
self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, item.details["_trace"])
self.assertEqual("<redacted>", item.details["password"])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_audit_event_publishes_governed_platform_event(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-event-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401
Base.metadata.create_all(bind=database.engine)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
with database.session() as session:
with event_bus_context(bus), event_context(correlation_id="corr-1"):
item = audit_event(
session,
tenant_id="tenant-1",
user_id="user-1",
scope="tenant",
action="user.updated",
object_type="user",
object_id="user-2",
details={"password": "secret", "field": "display_name"},
)
action_events = [event for event in seen if event.type == "user.updated"]
self.assertEqual(1, len(action_events))
event = action_events[0]
self.assertEqual("access", event.module_id)
self.assertEqual("corr-1", event.correlation_id)
self.assertEqual(EventActorRef(type="user", id="user-1"), event.actor)
self.assertEqual(EventTenantRef(id="tenant-1"), event.tenant)
self.assertEqual(EventObjectRef(type="user", id="user-2"), event.resource)
self.assertEqual(item.id, event.payload["audit_log_id"])
self.assertEqual("<redacted>", event.payload["details"]["password"])
finally:
shutil.rmtree(root, ignore_errors=True)
def test_audit_events_map_existing_module_action_prefixes(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-audit-module-events-"))
try:
with temporary_database(f"sqlite:///{root / 'audit.db'}") as database:
from govoplan_access.backend.db import models as access_models # noqa: F401
from govoplan_admin.backend.db import models as admin_models # noqa: F401
from govoplan_audit.backend.db import models as audit_models # noqa: F401
from govoplan_tenancy.backend.db import models as tenancy_models # noqa: F401
Base.metadata.create_all(bind=database.engine)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
cases = {
"user.created": "access",
"tenant.created": "tenancy",
"privacy_retention.policy_updated": "policy",
"files.connector.imported": "files",
"campaign.created": "campaigns",
"report.email_sent": "campaigns",
}
with database.session() as session:
with event_bus_context(bus):
for action in cases:
audit_event(
session,
tenant_id="tenant-1",
user_id="user-1",
action=action,
object_type="demo",
object_id=action,
)
by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type)
finally:
shutil.rmtree(root, ignore_errors=True)
def test_audit_operation_context_keeps_lifecycle_ids_and_sanitizes_details(self) -> None:
payload = audit_operation_context(
module_id="mail",
request_id="request-1",
run_id="run-1",
outcome="queued",
trace={"correlation_id": " corr-1 ", "causation_id": "cause-1", "token": "secret"},
options={"migrate_database": True, "api_key": "secret"},
empty=None,
)
self.assertEqual("mail", payload["module_id"])
self.assertEqual("request-1", payload["request_id"])
self.assertEqual("run-1", payload["run_id"])
self.assertEqual("queued", payload["outcome"])
self.assertEqual({"correlation_id": "corr-1", "causation_id": "cause-1"}, payload["_trace"])
self.assertEqual("<redacted>", payload["options"]["api_key"])
self.assertNotIn("token", payload["_trace"])
self.assertNotIn("empty", payload)
def test_trace_ids_are_normalized_for_headers_and_audit_input(self) -> None:
self.assertEqual("abc-123", normalize_trace_id(" abc-123 "))
self.assertIsNone(normalize_trace_id("../bad"))
self.assertIsNone(normalize_trace_id("x" * 129))
if __name__ == "__main__":
unittest.main()