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:
2026-08-03 22:59:00 +02:00
co-authored by Claude Opus 5
parent 6246b13247
commit 77e07e87dc
5 changed files with 70 additions and 16 deletions
+53
View File
@@ -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") == []