tenant-engine/tests/test_audit_core.py

225 lines
8.6 KiB
Python
Raw Normal View History

"""TEN-WP-0011-T04: local outbox and attributive drain to audit-core."""
Correct the audit-core envelope to the published wire contract audit-core registered our sender (AUDIT-IN-0002) and, reviewing the emitter, found that no event we sent could ever have been accepted. envelope_for sent five of the eight required fields under its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely. normalize() rejects that whole, 400. Our drain treated 400 as terminal, so every event would have been marked handled here while audit-core held only an unchained dead letter: lost on both sides, silently, with the integration looking healthy. - envelope_for emits exactly the eight required fields and none of the six audit-core derives. The acting principal moves into `data`, where it reads as our claim rather than the archive's finding. - Thread correlation_id through create / revoke / plan, which had no such field. Optional on those three bodies for compatibility; when a caller supplies none this engine mints req-<uuid> for the operation it performed and returns it. The store mints op-<uuid> as a floor for direct callers, written into the local payload so both records agree. - Send Idempotency-Key equal to the body id. - A 400 no longer dead-letters. The row stays pending with the reason recorded on it: a 400 is an integration defect to fix, not a delivery outcome to record. - wire_envelope upgrades outbox rows written in the old shape at send time, and refuses to send one whose correlation cannot be recovered from its payload rather than inventing one. Verified by running all nine event types, through the API, through audit-core's actual normalize() with a matching SenderIdentity -- all accepted. A live non-production 202 still needs the token, so TEN-WP-0012-T01 stays `wait`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFmHM6fugwfqoobUCp9GiQ Assistant: claude-code Assistant-Model: opus Assistant-Process: 2106375@bnt-lap001 Assistant-Session: aa26c34d-71e8-4478-a962-c79c74694dc8
2026-09-10 18:46:16 +02:00
import json
from datetime import datetime
import httpx
Correct the audit-core envelope to the published wire contract audit-core registered our sender (AUDIT-IN-0002) and, reviewing the emitter, found that no event we sent could ever have been accepted. envelope_for sent five of the eight required fields under its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely. normalize() rejects that whole, 400. Our drain treated 400 as terminal, so every event would have been marked handled here while audit-core held only an unchained dead letter: lost on both sides, silently, with the integration looking healthy. - envelope_for emits exactly the eight required fields and none of the six audit-core derives. The acting principal moves into `data`, where it reads as our claim rather than the archive's finding. - Thread correlation_id through create / revoke / plan, which had no such field. Optional on those three bodies for compatibility; when a caller supplies none this engine mints req-<uuid> for the operation it performed and returns it. The store mints op-<uuid> as a floor for direct callers, written into the local payload so both records agree. - Send Idempotency-Key equal to the body id. - A 400 no longer dead-letters. The row stays pending with the reason recorded on it: a 400 is an integration defect to fix, not a delivery outcome to record. - wire_envelope upgrades outbox rows written in the old shape at send time, and refuses to send one whose correlation cannot be recovered from its payload rather than inventing one. Verified by running all nine event types, through the API, through audit-core's actual normalize() with a matching SenderIdentity -- all accepted. A live non-production 202 still needs the token, so TEN-WP-0012-T01 stays `wait`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFmHM6fugwfqoobUCp9GiQ Assistant: claude-code Assistant-Model: opus Assistant-Process: 2106375@bnt-lap001 Assistant-Session: aa26c34d-71e8-4478-a962-c79c74694dc8
2026-09-10 18:46:16 +02:00
import pytest
from fastapi.testclient import TestClient
from helpers import AllowAllAuthorizer
from tenant_engine.app import create_app
Correct the audit-core envelope to the published wire contract audit-core registered our sender (AUDIT-IN-0002) and, reviewing the emitter, found that no event we sent could ever have been accepted. envelope_for sent five of the eight required fields under its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely. normalize() rejects that whole, 400. Our drain treated 400 as terminal, so every event would have been marked handled here while audit-core held only an unchained dead letter: lost on both sides, silently, with the integration looking healthy. - envelope_for emits exactly the eight required fields and none of the six audit-core derives. The acting principal moves into `data`, where it reads as our claim rather than the archive's finding. - Thread correlation_id through create / revoke / plan, which had no such field. Optional on those three bodies for compatibility; when a caller supplies none this engine mints req-<uuid> for the operation it performed and returns it. The store mints op-<uuid> as a floor for direct callers, written into the local payload so both records agree. - Send Idempotency-Key equal to the body id. - A 400 no longer dead-letters. The row stays pending with the reason recorded on it: a 400 is an integration defect to fix, not a delivery outcome to record. - wire_envelope upgrades outbox rows written in the old shape at send time, and refuses to send one whose correlation cannot be recovered from its payload rather than inventing one. Verified by running all nine event types, through the API, through audit-core's actual normalize() with a matching SenderIdentity -- all accepted. A live non-production 202 still needs the token, so TEN-WP-0012-T01 stays `wait`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFmHM6fugwfqoobUCp9GiQ Assistant: claude-code Assistant-Model: opus Assistant-Process: 2106375@bnt-lap001 Assistant-Session: aa26c34d-71e8-4478-a962-c79c74694dc8
2026-09-10 18:46:16 +02:00
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"
Correct the audit-core envelope to the published wire contract audit-core registered our sender (AUDIT-IN-0002) and, reviewing the emitter, found that no event we sent could ever have been accepted. envelope_for sent five of the eight required fields under its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely. normalize() rejects that whole, 400. Our drain treated 400 as terminal, so every event would have been marked handled here while audit-core held only an unchained dead letter: lost on both sides, silently, with the integration looking healthy. - envelope_for emits exactly the eight required fields and none of the six audit-core derives. The acting principal moves into `data`, where it reads as our claim rather than the archive's finding. - Thread correlation_id through create / revoke / plan, which had no such field. Optional on those three bodies for compatibility; when a caller supplies none this engine mints req-<uuid> for the operation it performed and returns it. The store mints op-<uuid> as a floor for direct callers, written into the local payload so both records agree. - Send Idempotency-Key equal to the body id. - A 400 no longer dead-letters. The row stays pending with the reason recorded on it: a 400 is an integration defect to fix, not a delivery outcome to record. - wire_envelope upgrades outbox rows written in the old shape at send time, and refuses to send one whose correlation cannot be recovered from its payload rather than inventing one. Verified by running all nine event types, through the API, through audit-core's actual normalize() with a matching SenderIdentity -- all accepted. A live non-production 202 still needs the token, so TEN-WP-0012-T01 stays `wait`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFmHM6fugwfqoobUCp9GiQ Assistant: claude-code Assistant-Model: opus Assistant-Process: 2106375@bnt-lap001 Assistant-Session: aa26c34d-71e8-4478-a962-c79c74694dc8
2026-09-10 18:46:16 +02:00
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