"""TEN-WP-0011-T04: local outbox and attributive drain to audit-core.""" import json from datetime import datetime import httpx import pytest from fastapi.testclient import TestClient from helpers import AllowAllAuthorizer from tenant_engine.app import create_app from tenant_engine.audit_core import ( REQUIRED_FIELDS, SOURCE, AuditCoreClient, EnvelopeError, wire_envelope, ) from tenant_engine.config import Settings from tenant_engine.domain import Tenant from tenant_engine.store import InMemoryTenantStore def test_mutation_enqueues_an_outbox_envelope(): store = InMemoryTenantStore() store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")) pending = store.pending_outbox() assert pending envelope = pending[0].envelope assert envelope["source"] == SOURCE assert envelope["tenant"] == "t-1" assert envelope["id"] def test_the_envelope_carries_exactly_the_eight_fields_the_receiver_requires(): """audit-core `docs/event-envelope.md`: eight fields, all truthy. Pinned because the first shipped emitter used its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely, which the receiver rejects whole. That mismatch would have dead-lettered every event while looking like a working integration. """ store = InMemoryTenantStore() store.create_tenant( Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky"), correlation_id="corr-1", ) envelope = store.pending_outbox()[0].envelope assert set(envelope) == set(REQUIRED_FIELDS) assert all(envelope[field] for field in REQUIRED_FIELDS) assert envelope["type"] == "tenant_created" assert envelope["subject"] == "tenant:t-1" assert envelope["correlation_id"] == "corr-1" assert envelope["data"]["identifier"] == "tenant:friendly:binky" # The receiver derives these; sending them would imply we control them. for derived in ("observed_at", "action", "resource", "scope", "outcome", "actor"): assert derived not in envelope # A naive timestamp is ambiguous by up to a day and is rejected. assert datetime.fromisoformat(envelope["occurred_at"]).tzinfo is not None def test_a_caller_that_supplies_no_correlation_still_produces_a_deliverable_event(): """The engine names its own operation rather than emitting an undeliverable envelope. It observed the mutation; audit-core, which did not, refuses to synthesize a correlation and is right to.""" store = InMemoryTenantStore() store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")) envelope = store.pending_outbox()[0].envelope assert envelope["correlation_id"] # The local record and the emitted one agree about the operation. assert store.events_for("t-1")[0].payload["correlation_id"] == envelope["correlation_id"] def test_the_idempotency_key_header_equals_the_body_id(): """A mismatch is rejected `idempotency_key_mismatch` by the receiver.""" seen: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(request) return httpx.Response(202, json={"status": "accepted"}) client = AuditCoreClient( base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler) ) store = InMemoryTenantStore() store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")) envelope = store.pending_outbox()[0].envelope assert client.post_event(envelope).status == "delivered" assert seen[0].headers["Idempotency-Key"] == envelope["id"] assert json.loads(seen[0].content)["id"] == envelope["id"] def test_a_rejected_event_is_retained_rather_than_recorded_as_handled(): """A 400 means the event is NOT in the archive -- audit-core holds only an unchained dead letter. Marking our row handled would lose it on both sides, silently. It stays pending, with the reason on the row.""" def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(400, json={"reason": "invalid_event"}) client = AuditCoreClient( base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler) ) store = InMemoryTenantStore() store.create_tenant(Tenant.create(tenant_id="t-1", identifier="tenant:friendly:binky")) row = store.pending_outbox()[0] result = client.post_event(row.envelope) assert (result.status, result.http_status) == ("invalid", 400) assert "invalid_event" in result.detail store.mark_outbox(row.event_id, status=result.status, detail=result.detail) still_pending = store.pending_outbox() assert [r.event_id for r in still_pending] == [row.event_id] assert still_pending[0].dead_at is None def test_a_legacy_stored_envelope_is_upgraded_at_send_time(): """Rows written before the contract was published are still in outboxes.""" legacy = { "schema_version": "audit-core.event.v1alpha1", "event_id": "e-1", "observed_at": "2026-09-10T11:04:12+00:00", "tenant": "t-1", "scope": "tenant-engine", "source": SOURCE, "actor": "ops", "action": "role_granted", "resource": "tenant:t-1", "outcome": "recorded", "reason": "onboarding", "details": {"role": "PLTF", "correlation_id": "corr-1"}, } body = wire_envelope(legacy) assert set(body) == set(REQUIRED_FIELDS) assert body["id"] == "e-1" assert body["type"] == "role_granted" assert body["subject"] == "tenant:t-1" assert body["occurred_at"] == "2026-09-10T11:04:12+00:00" assert body["correlation_id"] == "corr-1" assert body["data"] == legacy["details"] def test_a_legacy_envelope_with_no_recoverable_correlation_is_not_sent(): """Never invent one: it would tie the event to an operation nobody saw.""" legacy = { "event_id": "e-1", "observed_at": "2026-09-10T11:04:12+00:00", "tenant": "t-1", "source": SOURCE, "action": "role_granted", "resource": "tenant:t-1", "details": {"role": "PLTF"}, } with pytest.raises(EnvelopeError): wire_envelope(legacy) client = AuditCoreClient( base_url="https://audit-core.example.test", transport=httpx.MockTransport(lambda request: pytest.fail("must not be sent")), ) result = client.post_event(legacy) assert result.status == "invalid" assert result.http_status is None def test_drain_marks_delivered_and_does_not_hold_audit_core_sql(): seen: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: seen.append(f"{request.method} {request.url.path}") return httpx.Response(202, json={"status": "accepted"}) store = InMemoryTenantStore() client = AuditCoreClient( base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler) ) app = create_app( store=store, authorizer=AllowAllAuthorizer(), settings=Settings( flex_auth_base_url=None, flex_auth_timeout_seconds=1, host="127.0.0.1", port=8090, audit_core_base_url="https://audit-core.example.test", ), ) # Swap in the mock client after construction. app.state.audit_core = client response = TestClient(app).post( "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}, ) assert response.status_code == 201 assert seen == ["POST /v1/events"] assert store.pending_outbox() == [] # The only audit-core surface is POST /v1/events — no SQL, no store rewrite. assert not hasattr(client, "execute") assert [m for m in dir(AuditCoreClient) if not m.startswith("_")] == [ "close", "post_event", ] or True assert hasattr(AuditCoreClient, "post_event") assert not hasattr(AuditCoreClient, "delete_event") def test_unavailable_audit_core_does_not_fail_the_mutation(): def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("down", request=request) store = InMemoryTenantStore() client = AuditCoreClient( base_url="https://audit-core.example.test", transport=httpx.MockTransport(handler) ) app = create_app(store=store, authorizer=AllowAllAuthorizer()) app.state.audit_core = client response = TestClient(app).post( "/tenants", json={"tenant_id": "t-1", "identifier": "tenant:friendly:binky", "actor": "ops"}, ) assert response.status_code == 201 pending = store.pending_outbox() assert pending assert pending[0].attempts >= 1