AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
7.4 KiB
Python
222 lines
7.4 KiB
Python
import io
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from audit_core.ingestion import IngestionApplication
|
|
from audit_core.interface import BackendUnavailableError, RetentionPolicy
|
|
from audit_core.mock_file_backend import MockFileAuditBackend
|
|
from audit_core.sqlite_backend import SQLiteAuditBackend
|
|
|
|
|
|
def invoke(app, payload, *, token="opaque", key="evt-1", method="POST",
|
|
path="/v1/events", body=None, length=None):
|
|
raw = body if body is not None else json.dumps(payload).encode()
|
|
environ = {
|
|
"PATH_INFO": path,
|
|
"REQUEST_METHOD": method,
|
|
"CONTENT_LENGTH": str(len(raw)) if length is None else length,
|
|
"wsgi.input": io.BytesIO(raw),
|
|
"HTTP_AUTHORIZATION": f"Bearer {token}",
|
|
}
|
|
if key is not None:
|
|
environ["HTTP_IDEMPOTENCY_KEY"] = key
|
|
result = {}
|
|
out = b"".join(app(environ, lambda status, headers: result.update(status=status)))
|
|
return result["status"], (json.loads(out) if out else {})
|
|
|
|
|
|
def event(**overrides):
|
|
base = {
|
|
"id": "evt-1",
|
|
"type": "membership.added",
|
|
"source": "user-engine",
|
|
"subject": "membership-1",
|
|
"tenant": "tenant:friendly:binky",
|
|
"correlation_id": "corr-1",
|
|
"occurred_at": "2026-08-09T00:00:00+00:00",
|
|
"data": {"membership_id": "membership-1"},
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
@pytest.fixture
|
|
def app(tmp_path):
|
|
return IngestionApplication(SQLiteAuditBackend(str(tmp_path / "events.db")), "opaque")
|
|
|
|
|
|
# --- backend contract (T01) -------------------------------------------------
|
|
|
|
def test_refuses_a_non_durable_backend():
|
|
"""The development file backend must never become the production sink."""
|
|
with pytest.raises(ValueError, match="not durable"):
|
|
IngestionApplication(MockFileAuditBackend(base_dir="/tmp/unused"), "opaque")
|
|
|
|
|
|
def test_readiness_reports_custody_class(app):
|
|
status, body = invoke(app, None, path="/readyz", method="GET", body=b"")
|
|
assert status.startswith("200")
|
|
assert body["durable"] is True
|
|
assert body["custody_class"] == "development"
|
|
|
|
|
|
def test_accepted_events_are_durable_across_reopen(tmp_path):
|
|
path = str(tmp_path / "events.db")
|
|
first = IngestionApplication(SQLiteAuditBackend(path), "opaque")
|
|
assert invoke(first, event())[0].startswith("202")
|
|
|
|
reopened = IngestionApplication(SQLiteAuditBackend(path), "opaque")
|
|
status, body = invoke(reopened, event())
|
|
assert status.startswith("200") and body["status"] == "duplicate"
|
|
|
|
|
|
# --- idempotency and conflict (T01, T02) ------------------------------------
|
|
|
|
def test_accepts_once_and_replays_idempotently(app):
|
|
assert invoke(app, event())[0].startswith("202")
|
|
status, body = invoke(app, event())
|
|
assert status.startswith("200") and body["status"] == "duplicate"
|
|
|
|
|
|
def test_same_id_different_payload_is_a_conflict(app):
|
|
assert invoke(app, event())[0].startswith("202")
|
|
status, body = invoke(app, event(subject="membership-2"))
|
|
assert status.startswith("409")
|
|
assert body["error"] == "event_id_conflict"
|
|
|
|
|
|
# --- authentication (T02) ---------------------------------------------------
|
|
|
|
def test_rejects_wrong_credential(app):
|
|
assert invoke(app, event(), token="wrong")[0].startswith("401")
|
|
|
|
|
|
def test_non_ascii_credential_is_unauthorized_not_a_crash(app):
|
|
"""compare_digest raises TypeError on non-ASCII str; that is a 401."""
|
|
assert invoke(app, event(), token="wröng")[0].startswith("401")
|
|
|
|
|
|
# --- validation (T02, T07) --------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("payload,expected", [
|
|
(event(data={"password": "never"}), "secret_shaped_field"),
|
|
(event(source="somewhere-else"), "source_not_allowed"),
|
|
(event(occurred_at="2026-08-09T00:00:00"), "timestamp_missing_timezone"),
|
|
(event(occurred_at="not-a-date"), "invalid_timestamp"),
|
|
(event(tenant=""), "invalid_event"),
|
|
])
|
|
def test_rejects_bad_events(app, payload, expected):
|
|
status, body = invoke(app, payload)
|
|
assert status.startswith("400")
|
|
assert body["error"] == expected
|
|
|
|
|
|
def test_rejects_mismatched_idempotency_key(app):
|
|
status, body = invoke(app, event(), key="other")
|
|
assert status.startswith("400")
|
|
assert body["error"] == "idempotency_key_mismatch"
|
|
|
|
|
|
def test_rejects_malformed_json(app):
|
|
assert invoke(app, None, body=b"{not json")[0].startswith("400")
|
|
|
|
|
|
def test_rejects_empty_body(app):
|
|
status, body = invoke(app, None, body=b"")
|
|
assert status.startswith("400")
|
|
assert body["error"] == "empty_body"
|
|
|
|
|
|
def test_rejects_oversized_body(app):
|
|
status, body = invoke(app, None, body=b"x", length=str(512 * 1024))
|
|
assert status.startswith("400")
|
|
assert body["error"] == "payload_too_large"
|
|
|
|
|
|
def test_rejects_truncated_body(app):
|
|
status, body = invoke(app, None, body=b"{}", length="500")
|
|
assert status.startswith("400")
|
|
assert body["error"] == "truncated_body"
|
|
|
|
|
|
# --- routing (T07) ----------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("path,method", [
|
|
("/v1/events", "GET"),
|
|
("/nope", "POST"),
|
|
])
|
|
def test_unknown_routes_are_not_found(app, path, method):
|
|
assert invoke(app, event(), path=path, method=method)[0].startswith("404")
|
|
|
|
|
|
def test_healthz_needs_no_credential(app):
|
|
status, _ = invoke(app, None, path="/healthz", method="GET", body=b"", token="wrong")
|
|
assert status.startswith("200")
|
|
|
|
|
|
# --- failure handling (T02) -------------------------------------------------
|
|
|
|
class _BrokenBackend:
|
|
@property
|
|
def retention_policy(self):
|
|
return RetentionPolicy("archive", 3650, True, True, durable=True)
|
|
|
|
def emit(self, event):
|
|
raise BackendUnavailableError("down")
|
|
|
|
def accept(self, event, payload_hash):
|
|
raise BackendUnavailableError("down")
|
|
|
|
|
|
class _ExplodingBackend(_BrokenBackend):
|
|
def accept(self, event, payload_hash):
|
|
raise RuntimeError("unexpected")
|
|
|
|
|
|
def test_backend_unavailable_is_retryable_503():
|
|
status, body = invoke(IngestionApplication(_BrokenBackend(), "opaque"), event())
|
|
assert status.startswith("503")
|
|
assert body["error"] == "backend_unavailable"
|
|
|
|
|
|
def test_unexpected_backend_error_still_returns_a_response():
|
|
"""No request path may terminate without calling start_response."""
|
|
status, body = invoke(IngestionApplication(_ExplodingBackend(), "opaque"), event())
|
|
assert status.startswith("500")
|
|
assert body["error"] == "internal_error"
|
|
|
|
|
|
# --- concurrency (T01) ------------------------------------------------------
|
|
|
|
def test_concurrent_duplicates_produce_exactly_one_record(tmp_path):
|
|
"""Racing submissions of one event: one acceptance, one custody record.
|
|
|
|
This is the assertion the whole service rests on, so it is exercised rather
|
|
than assumed. An earlier single-connection implementation passed every
|
|
serial test while letting two callers both be told they were first.
|
|
"""
|
|
import threading
|
|
|
|
backend = SQLiteAuditBackend(str(tmp_path / "race.db"))
|
|
app = IngestionApplication(backend, "opaque")
|
|
outcomes: list[str] = []
|
|
lock = threading.Lock()
|
|
barrier = threading.Barrier(16)
|
|
|
|
def submit():
|
|
barrier.wait()
|
|
status, body = invoke(app, event())
|
|
with lock:
|
|
outcomes.append(body.get("status", status))
|
|
|
|
threads = [threading.Thread(target=submit) for _ in range(16)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
assert outcomes.count("accepted") == 1, outcomes
|
|
assert outcomes.count("duplicate") == 15, outcomes
|
|
stored = backend.db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
|
assert stored == 1
|