feat: add audit logging for admin trash restore/purge and notification clear actions

- Record audit events in `admin_trash_restore` and `admin_trash_purge` endpoints with target metadata
- Log `notification.read.all` event when user clears devRant notification feed
- Include `devrant` as a valid origin in audit categories
- Add `admin_section` and `pagination_query` fields to audit and backup schemas for UI consistency
This commit is contained in:
2026-06-16 05:08:58 +00:00
parent 1934dd5727
commit 99ed5c4f15
24 changed files with 466 additions and 186 deletions
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+81
View File
@@ -0,0 +1,81 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
JSON_trash = {"Accept": "application/json"}
_counter_trash = [0]
def _unique_trash(prefix="tr"):
_counter_trash[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_trash[0]}"
def _member_trash():
name = _unique_trash("trmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _admin_trash(seeded_db):
s = requests.Session()
creds = seeded_db["alice"]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _create_post_trash(session):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_trash,
data={"title": _unique_trash("trp"), "content": "trash post body text", "topic": "devlog"},
).json()["data"]
def _soft_delete_post_trash(session, slug):
return session.post(
f"{BASE_URL}/posts/delete/{slug}", headers=JSON_trash, allow_redirects=False
)
def test_trash_requires_admin(seeded_db):
member, _ = _member_trash()
r = member.get(f"{BASE_URL}/admin/trash", allow_redirects=False)
assert r.status_code in (302, 303)
def test_trash_lists_soft_deleted_post(seeded_db):
member, _ = _member_trash()
post = _create_post_trash(member)
_soft_delete_post_trash(member, post["slug"])
admin = _admin_trash(seeded_db)
body = admin.get(
f"{BASE_URL}/admin/trash", headers=JSON_trash, params={"table": "posts"}
).json()
assert body["table"] == "posts"
assert body["admin_section"] == "trash"
assert any(item["uid"] == post["uid"] for item in body["items"])
def test_trash_unknown_table_falls_back_to_posts(seeded_db):
admin = _admin_trash(seeded_db)
body = admin.get(
f"{BASE_URL}/admin/trash", headers=JSON_trash, params={"table": "bogus"}
).json()
assert body["table"] == "posts"
+104
View File
@@ -0,0 +1,104 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
JSON_trash = {"Accept": "application/json"}
_counter_trash = [0]
def _unique_trash(prefix="tr"):
_counter_trash[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_trash[0]}"
def _member_trash():
name = _unique_trash("trpmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _admin_trash(seeded_db):
s = requests.Session()
creds = seeded_db["alice"]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _create_post_trash(session):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_trash,
data={"title": _unique_trash("trpp"), "content": "purge post body text", "topic": "devlog"},
).json()["data"]
def _soft_delete_post_trash(session, slug):
session.post(f"{BASE_URL}/posts/delete/{slug}", headers=JSON_trash, allow_redirects=False)
def _audit_find(admin, event_key, target_uid):
data = admin.get(
f"{BASE_URL}/admin/audit-log", headers=JSON_trash, params={"event_key": event_key}
).json()
for entry in data["entries"]:
if entry.get("target_uid") == target_uid:
return entry
return None
def test_purge_requires_admin(seeded_db):
member, _ = _member_trash()
r = member.post(
f"{BASE_URL}/admin/trash/posts/anything/purge", allow_redirects=False
)
assert r.status_code in (302, 303)
def test_purge_unknown_table_404(seeded_db):
admin = _admin_trash(seeded_db)
r = admin.post(
f"{BASE_URL}/admin/trash/bogus/anything/purge",
headers=JSON_trash,
allow_redirects=False,
)
assert r.status_code == 404
def test_purge_hard_deletes_and_records_audit(seeded_db):
member, _ = _member_trash()
post = _create_post_trash(member)
_soft_delete_post_trash(member, post["slug"])
admin = _admin_trash(seeded_db)
r = admin.post(
f"{BASE_URL}/admin/trash/posts/{post['uid']}/purge",
headers=JSON_trash,
allow_redirects=False,
)
assert r.status_code == 200, r.text[:300]
# gone from trash and not restorable (row hard-deleted)
trash = admin.get(
f"{BASE_URL}/admin/trash", headers=JSON_trash, params={"table": "posts"}
).json()
assert all(item["uid"] != post["uid"] for item in trash["items"])
entry = _audit_find(admin, "admin.trash.purge", post["uid"])
assert entry is not None
assert entry["result"] == "success"
+105
View File
@@ -0,0 +1,105 @@
# retoor <retoor@molodetz.nl>
import time
import requests
from tests.conftest import BASE_URL
JSON_trash = {"Accept": "application/json"}
_counter_trash = [0]
def _unique_trash(prefix="tr"):
_counter_trash[0] += 1
return f"{prefix}{int(time.time() * 1000)}{_counter_trash[0]}"
def _member_trash():
name = _unique_trash("trrmem")
s = requests.Session()
s.post(
f"{BASE_URL}/auth/signup",
data={
"username": name,
"email": f"{name}@t.dev",
"password": "secret123",
"confirm_password": "secret123",
},
allow_redirects=True,
)
return s, name
def _admin_trash(seeded_db):
s = requests.Session()
creds = seeded_db["alice"]
s.post(
f"{BASE_URL}/auth/login",
data={"email": creds["email"], "password": creds["password"]},
allow_redirects=True,
)
return s
def _create_post_trash(session):
return session.post(
f"{BASE_URL}/posts/create",
headers=JSON_trash,
data={"title": _unique_trash("trrp"), "content": "restore post body text", "topic": "devlog"},
).json()["data"]
def _soft_delete_post_trash(session, slug):
session.post(f"{BASE_URL}/posts/delete/{slug}", headers=JSON_trash, allow_redirects=False)
def _audit_find(admin, event_key, target_uid):
data = admin.get(
f"{BASE_URL}/admin/audit-log", headers=JSON_trash, params={"event_key": event_key}
).json()
for entry in data["entries"]:
if entry.get("target_uid") == target_uid:
return entry
return None
def test_restore_requires_admin(seeded_db):
member, _ = _member_trash()
r = member.post(
f"{BASE_URL}/admin/trash/posts/anything/restore", allow_redirects=False
)
assert r.status_code in (302, 303)
def test_restore_unknown_table_404(seeded_db):
admin = _admin_trash(seeded_db)
r = admin.post(
f"{BASE_URL}/admin/trash/bogus/anything/restore",
headers=JSON_trash,
allow_redirects=False,
)
assert r.status_code == 404
def test_restore_revives_post_and_records_audit(seeded_db):
member, _ = _member_trash()
post = _create_post_trash(member)
_soft_delete_post_trash(member, post["slug"])
admin = _admin_trash(seeded_db)
r = admin.post(
f"{BASE_URL}/admin/trash/posts/{post['uid']}/restore",
headers=JSON_trash,
allow_redirects=False,
)
assert r.status_code == 200, r.text[:300]
# restored row is live again and gone from trash
trash = admin.get(
f"{BASE_URL}/admin/trash", headers=JSON_trash, params={"table": "posts"}
).json()
assert all(item["uid"] != post["uid"] for item in trash["items"])
assert member.get(f"{BASE_URL}/posts/{post['slug']}").status_code == 200
entry = _audit_find(admin, "admin.trash.restore", post["uid"])
assert entry is not None
assert entry["result"] == "success"