feat(audit): publish sequenced heartbeat and reconciliation evidence

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ec5-7e2b-7743-ac08-719e1b0f42e2
This commit is contained in:
tegwick 2026-09-05 01:39:48 +02:00
parent fce60099c4
commit b9349782f4
32 changed files with 839 additions and 111 deletions

View file

@ -1,7 +1,9 @@
from pathlib import Path
import httpx
import pytest
from qonto_assistant.app import create_app
from qonto_assistant.audit import AuditLogger
from qonto_assistant.config import Settings
from qonto_assistant.contracts import ActorClaims
@ -12,7 +14,9 @@ from qonto_assistant.qonto_client import QontoClient
from qonto_assistant.rate_limits import ConcurrencyLimiter, RateLimiter
from qonto_assistant.service import CapabilityService
POLICY_FILE = Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
POLICY_FILE = (
Path(__file__).resolve().parents[1] / "src" / "qonto_assistant" / "policy" / "qonto-v1.yaml"
)
def _settings() -> Settings:
@ -38,6 +42,7 @@ def _settings() -> Settings:
deny_escalation_threshold=3,
deny_escalation_window_seconds=60,
deny_escalation_lockout_seconds=300,
audit_heartbeat_interval_seconds=86400,
key_cape_jwks_url=None,
key_cape_issuer="https://key-cape.netkingdom",
key_cape_audience="qonto-assistant",
@ -227,3 +232,56 @@ def test_snapshot_contract_returns_cost_run_rate_hints_for_90_day_window(monkeyp
assert payload["summary"]["total_balance"] == 2185.94
assert payload["cost_run_rate_hints"]["recurring_debits"][0]["label"] == "HUB31"
assert any(event["capability"] == "snapshot_bundle" for event in events)
async def test_app_lifecycle_and_reconciliation_use_the_request_audit_stream(monkeypatch) -> None:
service, events = _service(monkeypatch)
app = create_app(settings=_settings(), service=service)
async with app.router.lifespan_context(app):
assert events[-1]["event_class"] == "audit.heartbeat"
assert events[-1]["reason"] == "startup"
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get(
"/v1/audit/reconciliation",
headers={"X-Actor-ID": "observer", "X-Tenant-ID": "binky"},
)
assert response.status_code == 200
assert response.json()["last_stream_sequence"] == 1
assert response.json()["source_transition_counts"] == {
"audit.allow": 0,
"audit.deny": 0,
}
assert len(events) == 1
assert events[-1]["event_class"] == "audit.heartbeat"
assert events[-1]["reason"] == "shutdown"
assert events[-1]["stream_sequence"] == 2
async def test_client_closes_when_shutdown_heartbeat_fails(monkeypatch) -> None:
service, _ = _service(monkeypatch)
closed = []
monkeypatch.setattr(service.client, "close", lambda: closed.append(True))
def failing_shutdown(payload):
if payload.get("reason") == "shutdown":
raise RuntimeError("sink unavailable")
service.audit_logger.sink = failing_shutdown
app = create_app(settings=_settings(), service=service)
with pytest.raises(ExceptionGroup) as caught:
async with app.router.lifespan_context(app):
pass
assert caught.group_contains(RuntimeError, match="sink unavailable")
assert closed == [True]
def test_app_rejects_split_audit_streams(monkeypatch) -> None:
service, _ = _service(monkeypatch)
with pytest.raises(ValueError, match="share one audit_logger"):
create_app(settings=_settings(), service=service, audit_logger=AuditLogger())