Audit-log bug report status changes (B-60)
Every other admin mutation — config edit, pause/resume, privkey export, password reset — leaves a trace; this one could silently mark a report resolved. With one shared ADMIN_TOKEN and no per-admin identity, the audit log is the only accountability there is. bug_report_status_changed records the report id and the before/after status, and carries the report's author as user_id so the entry is traceable from either side. Nothing is written when the status doesn't actually change, matching config_updated: an edit that changes nothing isn't an event, and noise hides the real changes. Also documents the new event in docs/guida-admin.md's audit table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -179,3 +179,56 @@ async def test_update_status_unknown_report_is_404(client):
|
||||
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- B-60: the status change is an admin mutation, so it leaves a trace ----------
|
||||
|
||||
|
||||
async def _audit_entries(event_type: str) -> list:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.db.models import AuditLog
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
return (
|
||||
await session.scalars(select(AuditLog).where(AuditLog.event_type == event_type))
|
||||
).all()
|
||||
|
||||
|
||||
async def test_status_change_is_audit_logged(client): # B-60
|
||||
"""One shared ADMIN_TOKEN and no per-admin identity means the audit log is the
|
||||
only accountability there is — a report could be silently marked resolved."""
|
||||
token = await _register(client, username="reporter")
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
json={"description": "some bug"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
|
||||
entries = await _audit_entries("bug_report_status_changed")
|
||||
assert len(entries) == 1
|
||||
import json
|
||||
|
||||
payload = json.loads(entries[0].payload_json)
|
||||
assert payload == {"report_id": report_id, "from": "open", "to": "resolved"}
|
||||
assert entries[0].user_id is not None # the report's author, so it's traceable both ways
|
||||
|
||||
|
||||
async def test_setting_the_status_it_already_has_logs_nothing(client): # B-60
|
||||
"""Same rule as config_updated: an edit that changes nothing isn't an event, or
|
||||
the log fills with noise that hides the real changes."""
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
for _ in range(3):
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
|
||||
assert await _audit_entries("bug_report_status_changed") == []
|
||||
|
||||
Reference in New Issue
Block a user