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 EventBus, PlatformEvent, current_event_trace, 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_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("", item.details["password"]) 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("", 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()