Surface the admin_audit table (written by every mutating admin action but previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list (LEFT JOIN users for the actor's username, filter by action/target_kind/actor/ since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the crawler history endpoint's paged/clamped idiom. Frontend: a self-contained AuditTable (target-kind + window + action filters, expandable JSON payload, AbortController-cancelled fetches, loading/error/empty states) and shared fmtAgo() in format.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
5.5 KiB
Rust
176 lines
5.5 KiB
Rust
//! Integration tests for the admin audit-log viewer: the `repo::admin_audit`
|
|
//! list query (filters, username join, NULL actor) and the
|
|
//! `GET /v1/admin/audit` endpoint (admin-gating, shape, filter params).
|
|
|
|
mod common;
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::Router;
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
use common::{body_json, get_with_cookie, harness, register_user};
|
|
use mangalord::repo::admin_audit::{self, AuditFilter};
|
|
|
|
async fn seed_admin(pool: &PgPool, app: &Router) -> (Uuid, String) {
|
|
let (username, cookie) = register_user(app).await;
|
|
let u = mangalord::repo::user::find_by_username(pool, &username)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
mangalord::repo::user::set_is_admin_unchecked(pool, u.id, true)
|
|
.await
|
|
.unwrap();
|
|
(u.id, cookie)
|
|
}
|
|
|
|
/// Insert an audit row with an explicit `at` so ordering/since are testable.
|
|
async fn insert_audit(
|
|
pool: &PgPool,
|
|
actor: Option<Uuid>,
|
|
action: &str,
|
|
target_kind: &str,
|
|
at: &str,
|
|
) {
|
|
sqlx::query(
|
|
"INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload, at) \
|
|
VALUES ($1, $2, $3, NULL, $4, $5::timestamptz)",
|
|
)
|
|
.bind(actor)
|
|
.bind(action)
|
|
.bind(target_kind)
|
|
.bind(json!({ "k": action }))
|
|
.bind(at)
|
|
.execute(pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn list_joins_username_and_orders_newest_first(pool: PgPool) {
|
|
let h = harness(pool.clone());
|
|
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
|
insert_audit(&pool, None, "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
|
|
|
let (items, total) = admin_audit::list(&pool, AuditFilter::default(), 50, 0)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 2);
|
|
// Newest first.
|
|
assert_eq!(items[0].action, "manga_resync");
|
|
assert_eq!(items[1].action, "crawler_run");
|
|
// Username join: the admin actor resolves; the NULL actor stays None.
|
|
assert_eq!(items[1].actor_user_id, Some(admin_id));
|
|
assert!(items[1].actor_username.is_some());
|
|
assert_eq!(items[0].actor_user_id, None);
|
|
assert_eq!(items[0].actor_username, None);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn list_filters_by_action_target_kind_actor_and_since(pool: PgPool) {
|
|
let h = harness(pool.clone());
|
|
let (admin_id, _cookie) = seed_admin(&pool, &h.app).await;
|
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-05-01T00:00:00Z").await;
|
|
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-10T00:00:00Z").await;
|
|
insert_audit(&pool, None, "crawler_run", "crawler", "2026-06-11T00:00:00Z").await;
|
|
|
|
// Filter by action.
|
|
let (items, total) = admin_audit::list(
|
|
&pool,
|
|
AuditFilter { action: Some("crawler_run"), ..Default::default() },
|
|
50,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 2);
|
|
assert!(items.iter().all(|r| r.action == "crawler_run"));
|
|
|
|
// Filter by target_kind.
|
|
let (_items, total) = admin_audit::list(
|
|
&pool,
|
|
AuditFilter { target_kind: Some("manga"), ..Default::default() },
|
|
50,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 1);
|
|
|
|
// Filter by actor.
|
|
let (_items, total) = admin_audit::list(
|
|
&pool,
|
|
AuditFilter { actor_user_id: Some(admin_id), ..Default::default() },
|
|
50,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 2);
|
|
|
|
// Filter by since (only the two June rows).
|
|
let since = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&chrono::Utc);
|
|
let (_items, total) = admin_audit::list(
|
|
&pool,
|
|
AuditFilter { since: Some(since), ..Default::default() },
|
|
50,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(total, 2);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn endpoint_requires_admin(pool: PgPool) {
|
|
let h = harness(pool.clone());
|
|
let (_u, cookie) = register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn endpoint_returns_paged_shape_and_honors_filter(pool: PgPool) {
|
|
let h = harness(pool.clone());
|
|
let (admin_id, cookie) = seed_admin(&pool, &h.app).await;
|
|
insert_audit(&pool, Some(admin_id), "crawler_run", "crawler", "2026-06-01T00:00:00Z").await;
|
|
insert_audit(&pool, Some(admin_id), "manga_resync", "manga", "2026-06-02T00:00:00Z").await;
|
|
|
|
// Unfiltered: both rows + paged envelope.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(get_with_cookie("/api/v1/admin/audit", &cookie))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 2);
|
|
assert_eq!(body["items"].as_array().unwrap().len(), 2);
|
|
assert!(body["items"][0]["actor_username"].is_string());
|
|
|
|
// Filter by action.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(get_with_cookie(
|
|
"/api/v1/admin/audit?action=manga_resync",
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let body = body_json(resp).await;
|
|
assert_eq!(body["page"]["total"], 1);
|
|
assert_eq!(body["items"][0]["action"], "manga_resync");
|
|
assert_eq!(body["items"][0]["target_kind"], "manga");
|
|
}
|