"""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)