claimflow-api / tests /test_audit.py
Minifigures's picture
feat: ClaimFlow API demo backend
ceea9e1 verified
Raw
History Blame Contribute Delete
5.49 kB
from pathlib import Path
import pytest
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from app import db
from app.claimguard import audit
from app.models import AuditEvent, AuditEventType
def _build_chain(session: Session) -> list[AuditEvent]:
events = [
audit.append(
session,
AuditEventType.CLAIM_SUBMIT,
claim_id=1,
actor_user_id=10,
actor_role="claimant",
payload={"amount": 250.0, "claim_type": "imaging"},
),
audit.append(
session,
"custom.review",
claim_id=1,
actor_user_id=11,
actor_role="imaging_specialist",
payload={"note": "scan looks consistent"},
),
audit.append(
session,
AuditEventType.WORKFLOW_TRANSITION,
claim_id=2,
payload={"from": "SUBMITTED", "to": "IMAGING_REVIEW"},
),
audit.append(
session,
AuditEventType.DECISION_APPROVE,
claim_id=2,
actor_user_id=12,
actor_role="insurance_agent",
),
audit.append(session, "auth.failed", payload={"email": "intruder@demo.ca"}),
audit.append(
session,
AuditEventType.LLM_CALL,
claim_id=3,
actor_user_id=13,
actor_role="system",
payload={"model": "stub", "tokens": 42},
),
]
session.commit()
return events
def test_append_and_verify_mixed_events(session: Session) -> None:
events = _build_chain(session)
assert len(events) == 6
assert events[0].event_type == "claim.submit"
assert events[1].event_type == "custom.review"
assert audit.verify_chain(session) == (True, 6)
def test_empty_chain_verifies(session: Session) -> None:
assert audit.verify_chain(session) == (True, 0)
def test_first_event_prev_hash_is_genesis(session: Session) -> None:
event = audit.append(
session, AuditEventType.AUTH_LOGIN, actor_user_id=1, actor_role="claimant"
)
session.commit()
assert audit.GENESIS == "GENESIS"
assert event.prev_hash == audit.GENESIS
def test_each_prev_hash_links_to_previous_record_hash(session: Session) -> None:
_build_chain(session)
session.expire_all()
rows = session.scalars(select(AuditEvent).order_by(AuditEvent.id.asc())).all()
assert rows[0].prev_hash == audit.GENESIS
for prev, curr in zip(rows, rows[1:], strict=False):
assert curr.prev_hash == prev.record_hash
@pytest.mark.parametrize(
("column", "value"),
[
("payload_json", '{"note":"totally different"}'),
("event_type", "decision.reject"),
("claim_id", 777),
("actor_user_id", 999),
("actor_role", "insurance_agent"),
],
)
def test_tampering_middle_event_breaks_chain(
session: Session, column: str, value: str | int
) -> None:
events = _build_chain(session)
assert audit.verify_chain(session) == (True, len(events))
target = events[1]
assert getattr(target, column) != value
stmt = update(AuditEvent).where(AuditEvent.id == target.id).values(**{column: value})
session.execute(stmt)
session.commit()
session.expire_all()
assert audit.verify_chain(session) == (False, len(events))
def test_tampering_actor_attribution_breaks_chain(session: Session) -> None:
events = _build_chain(session)
target = events[3]
stmt = (
update(AuditEvent)
.where(AuditEvent.id == target.id)
.values(actor_user_id=10, actor_role="claimant")
)
session.execute(stmt)
session.commit()
session.expire_all()
assert audit.verify_chain(session) == (False, len(events))
def test_deleting_middle_row_breaks_chain(session: Session) -> None:
events = _build_chain(session)
session.execute(delete(AuditEvent).where(AuditEvent.id == events[2].id))
session.commit()
session.expire_all()
assert audit.verify_chain(session) == (False, len(events) - 1)
def test_record_hash_recomputation_is_deterministic(session: Session) -> None:
event = audit.append(
session,
AuditEventType.DOCUMENT_UPLOAD,
claim_id=4,
actor_user_id=10,
actor_role="claimant",
payload={"filename": "xray.dcm", "size_bytes": 1024},
)
session.commit()
recomputed = [
audit._record_hash(
event_type=event.event_type,
claim_id=event.claim_id,
actor_user_id=event.actor_user_id,
actor_role=event.actor_role,
payload_json=event.payload_json,
created_at=event.created_at,
prev_hash=event.prev_hash,
)
for _ in range(2)
]
assert recomputed[0] == recomputed[1] == event.record_hash
def test_payload_key_order_is_irrelevant(session: Session, tmp_path: Path) -> None:
engine = db.make_engine(f"sqlite:///{tmp_path}/second-chain.sqlite")
db.Base.metadata.create_all(engine)
other = Session(engine)
try:
first = audit.append(session, "order.test", payload={"a": 1, "b": 2})
second = audit.append(other, "order.test", payload={"b": 2, "a": 1})
session.commit()
other.commit()
assert first.prev_hash == second.prev_hash == audit.GENESIS
assert first.payload_json == second.payload_json == '{"a":1,"b":2}'
finally:
other.close()
engine.dispose()