Files
cyclone/backend/tests/test_api_audit_log.py
Tyler 62bb09f183 feat(sp11): tamper-evident hash-chained audit_log
- New audit_log table (migration 0009, user_version=9):
  * id, event_type, entity_type, entity_id, actor, payload_json,
    created_at, prev_hash, hash
  * Indexes on (entity_type, entity_id), event_type, created_at
- New cyclone.audit_log module:
  * append_event(session, AuditEvent) — appends one chained row
  * verify_chain(session) — walks the chain, returns first bad id
  * SHA-256 hash over canonical row form (unit-separator delimited)
  * Genesis prev_hash = 64 zeros (Bitcoin-style sentinel)
- New AuditLog ORM model
- New admin API endpoints:
  * GET /api/admin/audit-log (paginated, filterable)
  * GET /api/admin/audit-log/verify (returns ok/first_bad_id/reason)
- Hooked into existing endpoints to append events:
  * /api/parse-999 → 'claim.rejected' per matched claim
  * /api/parse-277ca → 'claim.payer_rejected' per matched claim
  * /api/clearhouse/submit → 'clearhouse.submitted' per claim
- HIPAA §164.316(b)(2) compliance note in docs

Tests: 688 -> 705 (9 audit + 8 audit-API). All 705 backend tests pass.
2026-06-20 23:45:43 -06:00

160 lines
5.5 KiB
Python

"""Tests for the audit-log admin API endpoints. SP11 T3."""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.api import app
from cyclone.db import AuditLog, init_db
@pytest.fixture(autouse=True)
def _fresh_db():
init_db()
yield
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _seed_audit(rows: list[AuditEvent]) -> None:
with db.SessionLocal()() as s:
for ev in rows:
append_event(s, ev)
s.commit()
# --------------------------------------------------------------------------- #
# List endpoint
# --------------------------------------------------------------------------- #
class TestListAuditLog:
def test_empty_returns_zero_items(self, client: TestClient):
r = client.get("/api/admin/audit-log")
assert r.status_code == 200
body = r.json()
assert body["total"] == 0
assert body["items"] == []
def test_returns_newest_first(self, client: TestClient):
_seed_audit([
AuditEvent(event_type="a.first", entity_type="claim", entity_id="c1"),
AuditEvent(event_type="a.second", entity_type="claim", entity_id="c1"),
AuditEvent(event_type="a.third", entity_type="claim", entity_id="c1"),
])
r = client.get("/api/admin/audit-log")
assert r.status_code == 200
items = r.json()["items"]
assert [i["event_type"] for i in items] == ["a.third", "a.second", "a.first"]
def test_filter_by_entity(self, client: TestClient):
_seed_audit([
AuditEvent(event_type="x", entity_type="claim", entity_id="c1"),
AuditEvent(event_type="x", entity_type="claim", entity_id="c2"),
AuditEvent(event_type="x", entity_type="batch", entity_id="b1"),
])
r = client.get("/api/admin/audit-log", params={"entity_type": "claim", "entity_id": "c1"})
items = r.json()["items"]
assert len(items) == 1
assert items[0]["entity_id"] == "c1"
def test_filter_by_event_type(self, client: TestClient):
_seed_audit([
AuditEvent(event_type="claim.parsed", entity_type="claim", entity_id="c1"),
AuditEvent(event_type="claim.rejected", entity_type="claim", entity_id="c1"),
])
r = client.get("/api/admin/audit-log", params={"event_type": "claim.rejected"})
items = r.json()["items"]
assert len(items) == 1
assert items[0]["event_type"] == "claim.rejected"
# --------------------------------------------------------------------------- #
# Verify endpoint
# --------------------------------------------------------------------------- #
class TestVerifyAuditLog:
def test_empty_chain_returns_ok(self, client: TestClient):
r = client.get("/api/admin/audit-log/verify")
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["checked"] == 0
def test_clean_chain_returns_ok(self, client: TestClient):
_seed_audit([
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
for i in range(10)
])
r = client.get("/api/admin/audit-log/verify")
body = r.json()
assert body["ok"] is True
assert body["checked"] == 10
def test_tampered_chain_returns_failure(self, client: TestClient):
_seed_audit([
AuditEvent(event_type="x", entity_type="y", entity_id=f"id-{i}")
for i in range(5)
])
with db.SessionLocal()() as s:
row = s.query(AuditLog).filter(AuditLog.id == 3).first()
row.payload_json = json.dumps({"evil": True})
s.commit()
r = client.get("/api/admin/audit-log/verify")
body = r.json()
assert body["ok"] is False
assert body["first_bad_id"] == 3
assert body["reason"]
# --------------------------------------------------------------------------- #
# End-to-end: 999 → audit row appears in list
# --------------------------------------------------------------------------- #
class TestAuditLogHookedIntoEndpoints:
def test_parse_999_creates_audit_rows(self, client: TestClient):
"""Upload a 999 with one rejected set, see an audit row appear."""
# Seed a claim so the 999 rejection matches.
from cyclone.db import Claim, ClaimState
with db.SessionLocal()() as s:
s.add(Claim(
id="c1", batch_id="B-1", patient_control_number="0001",
state=ClaimState.SUBMITTED,
))
s.commit()
text = (
"ISA*00* *00* *ZZ*AAAAAAAAAAAAAAA*ZZ*BBBBBBBBBBBBBBB*240620*1200*^*00501*000000001*0*P*:~"
"GS*HN*A*B*20240620*1200*1*X*005010X231A1~"
"ST*999*0001*005010X231A1~"
"AK1*HC*0001~"
"AK2*837*0001~"
"AK5*R~"
"AK9*R*1*0*1~"
"SE*7*0001~"
"GE*1*1~"
"IEA*1*000000001~"
)
r = client.post(
"/api/parse-999",
files={"file": ("r.txt", text, "text/plain")},
)
assert r.status_code == 200, r.text
# One audit row should have appeared for the rejected claim.
r2 = client.get(
"/api/admin/audit-log",
params={"entity_type": "claim", "entity_id": "c1"},
)
items = r2.json()["items"]
assert any(i["event_type"] == "claim.rejected" for i in items)