diff --git a/backend/src/api/admin/audit.rs b/backend/src/api/admin/audit.rs new file mode 100644 index 0000000..bc443fe --- /dev/null +++ b/backend/src/api/admin/audit.rs @@ -0,0 +1,70 @@ +//! GET /admin/audit — paginated, filterable admin-action audit log. +//! +//! A pure DB read over the `admin_audit` table joined to the actor's +//! username. Drives the "Audit" admin tab: who did what, when. Every +//! mutating admin action writes a row here; this is the only reader. + +use axum::extract::{Query, State}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::app::AppState; +use crate::auth::extractor::RequireAdmin; +use crate::error::AppResult; +use crate::repo; +use crate::repo::admin_audit::{AuditEntryRow, AuditFilter}; + +// Reuse the analysis window helper so `days` clamps identically everywhere. +use crate::api::admin::analysis::window_since; + +pub fn routes() -> Router { + Router::new().route("/admin/audit", get(list_audit)) +} + +fn default_limit() -> i64 { + 50 +} + +#[derive(Debug, Deserialize, Default)] +struct AuditParams { + #[serde(default)] + action: Option, + #[serde(default)] + target_kind: Option, + #[serde(default)] + actor_user_id: Option, + #[serde(default)] + days: i64, + #[serde(default = "default_limit")] + limit: i64, + #[serde(default)] + offset: i64, +} + +async fn list_audit( + State(state): State, + _admin: RequireAdmin, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let action = params.action.filter(|s| !s.trim().is_empty()); + let target_kind = params.target_kind.filter(|s| !s.trim().is_empty()); + let (items, total) = repo::admin_audit::list( + &state.db, + AuditFilter { + action: action.as_deref(), + target_kind: target_kind.as_deref(), + actor_user_id: params.actor_user_id, + since: window_since(params.days), + }, + limit, + offset, + ) + .await?; + Ok(Json(crate::api::pagination::PagedResponse::with_total( + items, limit, offset, total, + ))) +} diff --git a/backend/src/api/admin/mod.rs b/backend/src/api/admin/mod.rs index 1f33ae5..34cea80 100644 --- a/backend/src/api/admin/mod.rs +++ b/backend/src/api/admin/mod.rs @@ -5,6 +5,7 @@ //! `crate::auth::extractor::RequireAdmin`). pub mod analysis; +pub mod audit; pub mod crawler; pub mod mangas; pub mod overview; @@ -26,6 +27,7 @@ pub fn routes() -> Router { .merge(storage::routes()) .merge(system::routes()) .merge(overview::routes()) + .merge(audit::routes()) .merge(crawler::routes()) .merge(analysis::routes()) .merge(settings::routes()) diff --git a/backend/src/repo/admin_audit.rs b/backend/src/repo/admin_audit.rs index a29261e..87498b1 100644 --- a/backend/src/repo/admin_audit.rs +++ b/backend/src/repo/admin_audit.rs @@ -1,14 +1,98 @@ -//! Admin-action audit log writes. +//! Admin-action audit log writes + the admin-facing read query. //! //! Insert is always called from inside the same transaction as the //! action it audits — the executor parameter is `PgExecutor` so the -//! caller passes `&mut *tx` directly. +//! caller passes `&mut *tx` directly. [`list`] backs the audit-log viewer. -use sqlx::PgExecutor; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::{FromRow, PgExecutor, PgPool}; use uuid::Uuid; use crate::error::AppResult; +/// One audit row joined to the actor's current username (NULL when the +/// actor account was deleted — `actor_user_id` is `ON DELETE SET NULL`). +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AuditEntryRow { + pub id: Uuid, + pub actor_user_id: Option, + pub actor_username: Option, + pub action: String, + pub target_kind: String, + pub target_id: Option, + pub payload: serde_json::Value, + pub at: DateTime, +} + +/// Optional filters for [`list`]. `None`/absent widens the scope; an +/// empty-string action/target_kind is treated as absent by the caller. +#[derive(Debug, Default, Clone)] +pub struct AuditFilter<'a> { + pub action: Option<&'a str>, + pub target_kind: Option<&'a str>, + pub actor_user_id: Option, + pub since: Option>, +} + +/// Paginated, newest-first audit log. Returns the page slice plus the +/// filtered total. Ordering by `at DESC` uses `admin_audit_at_idx`. +pub async fn list( + pool: &PgPool, + filter: AuditFilter<'_>, + limit: i64, + offset: i64, +) -> AppResult<(Vec, i64)> { + let items = sqlx::query_as::<_, AuditEntryRow>( + r#" + SELECT + a.id, + a.actor_user_id, + u.username AS actor_username, + a.action, + a.target_kind, + a.target_id, + a.payload, + a.at + FROM admin_audit a + LEFT JOIN users u ON u.id = a.actor_user_id + WHERE ($1::text IS NULL OR a.action = $1) + AND ($2::text IS NULL OR a.target_kind = $2) + AND ($3::uuid IS NULL OR a.actor_user_id = $3) + AND ($4::timestamptz IS NULL OR a.at >= $4) + ORDER BY a.at DESC + LIMIT $5 OFFSET $6 + "#, + ) + .bind(filter.action) + .bind(filter.target_kind) + .bind(filter.actor_user_id) + .bind(filter.since) + .bind(limit) + .bind(offset) + .fetch_all(pool) + .await?; + + let (total,): (i64,) = sqlx::query_as( + r#" + SELECT COUNT(*) + FROM admin_audit a + WHERE ($1::text IS NULL OR a.action = $1) + AND ($2::text IS NULL OR a.target_kind = $2) + AND ($3::uuid IS NULL OR a.actor_user_id = $3) + AND ($4::timestamptz IS NULL OR a.at >= $4) + "#, + ) + .bind(filter.action) + .bind(filter.target_kind) + .bind(filter.actor_user_id) + .bind(filter.since) + .fetch_one(pool) + .await?; + + Ok((items, total)) +} + pub async fn insert<'e, E: PgExecutor<'e>>( executor: E, actor_user_id: Uuid, diff --git a/backend/tests/api_admin_audit.rs b/backend/tests/api_admin_audit.rs new file mode 100644 index 0000000..515e3fb --- /dev/null +++ b/backend/tests/api_admin_audit.rs @@ -0,0 +1,175 @@ +//! 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, + 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"); +} diff --git a/frontend/src/lib/api/admin.test.ts b/frontend/src/lib/api/admin.test.ts index eb12041..7ac9298 100644 --- a/frontend/src/lib/api/admin.test.ts +++ b/frontend/src/lib/api/admin.test.ts @@ -42,7 +42,8 @@ import { listAnalysisHistory, getCrawlerMetrics, listCrawlerOps, - getAnalysisMetrics + getAnalysisMetrics, + listAuditLog } from './admin'; function ok(body: unknown, status = 200): Response { @@ -471,6 +472,40 @@ describe('admin crawler api client', () => { expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/crawler\/covers$/); }); + it('listAuditLog GETs /v1/admin/audit with the paged envelope', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ + items: [ + { + id: 'a-1', + actor_user_id: 'u-1', + actor_username: 'alice', + action: 'crawler_run', + target_kind: 'crawler', + target_id: null, + payload: {}, + at: '2026-06-01T00:00:00Z' + } + ], + page: { limit: 25, offset: 0, total: 1 } + }) + ); + const r = await listAuditLog({ limit: 25 }); + expect(r.items[0].action).toBe('crawler_run'); + expect(fetchSpy.mock.calls[0][0]).toMatch(/\/v1\/admin\/audit\?limit=25$/); + }); + + it('listAuditLog forwards action / target_kind / days filters', async () => { + fetchSpy.mockResolvedValueOnce( + ok({ items: [], page: { limit: 25, offset: 0, total: 0 } }) + ); + await listAuditLog({ action: 'manga_resync', targetKind: 'manga', days: 7 }); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('action=manga_resync'); + expect(url).toContain('target_kind=manga'); + expect(url).toContain('days=7'); + }); + it('runCrawlerPass POSTs /v1/admin/crawler/run', async () => { fetchSpy.mockResolvedValueOnce(ok({ started: true })); const r = await runCrawlerPass(); diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 38e8e58..78e62ce 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -978,3 +978,43 @@ export async function updateAnalysisSettings( body: JSON.stringify(settings) }); } + +// ---- admin audit log ------------------------------------------------------- + +export type AuditEntry = { + id: string; + actor_user_id: string | null; + /** Actor's current username; null when the account was deleted. */ + actor_username: string | null; + action: string; + target_kind: string; + target_id: string | null; + payload: unknown; + at: string; +}; + +export type AuditPage = { items: AuditEntry[]; page: Page }; + +export type ListAuditOptions = { + action?: string; + targetKind?: string; + actorUserId?: string; + days?: number; + limit?: number; + offset?: number; +}; + +export async function listAuditLog( + opts: ListAuditOptions = {}, + init?: RequestInit +): Promise { + const params = new URLSearchParams(); + if (opts.action) params.set('action', opts.action); + if (opts.targetKind) params.set('target_kind', opts.targetKind); + if (opts.actorUserId) params.set('actor_user_id', opts.actorUserId); + if (opts.days != null && opts.days > 0) params.set('days', String(opts.days)); + if (opts.limit != null) params.set('limit', String(opts.limit)); + if (opts.offset != null) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return request(`/v1/admin/audit${qs ? `?${qs}` : ''}`, init); +} diff --git a/frontend/src/lib/components/admin/AuditTable.svelte b/frontend/src/lib/components/admin/AuditTable.svelte new file mode 100644 index 0000000..0bbd661 --- /dev/null +++ b/frontend/src/lib/components/admin/AuditTable.svelte @@ -0,0 +1,235 @@ + + +
+
+ + + +
+ + {#if error} + + {/if} + + {#if loading} +

Loading…

+ {:else if rows.length === 0} +

No audit entries match.

+ {:else} + + + + + + + + + + + + {#each rows as r (r.id)} + (expanded = expanded === r.id ? null : r.id)} + data-testid={`audit-row-${r.id}`} + > + + + + + + + {#if expanded === r.id} + + + + {/if} + {/each} + +
WhenActorActionTarget
{fmtAgo(r.at)}{actor(r)}{r.action}{targetLabel(r)} + +
{pretty(r.payload)}
+ + {/if} +
+ + diff --git a/frontend/src/lib/components/admin/AuditTable.svelte.test.ts b/frontend/src/lib/components/admin/AuditTable.svelte.test.ts new file mode 100644 index 0000000..3598c1d --- /dev/null +++ b/frontend/src/lib/components/admin/AuditTable.svelte.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/svelte'; +import AuditTable from './AuditTable.svelte'; +import * as adminApi from '$lib/api/admin'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function entry(over: Partial = {}): adminApi.AuditEntry { + return { + id: 'a-1', + actor_user_id: 'u-1', + actor_username: 'alice', + action: 'crawler_run', + target_kind: 'crawler', + target_id: null, + payload: { started: true }, + at: '2026-06-01T00:00:00Z', + ...over + }; +} + +describe('AuditTable', () => { + it('renders rows with actor + action and expands the payload on click', async () => { + vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({ + items: [entry()], + page: { limit: 25, offset: 0, total: 1 } + }); + render(AuditTable); + + await waitFor(() => expect(screen.getByText('crawler_run')).toBeTruthy()); + expect(screen.getByText('alice')).toBeTruthy(); + // Payload hidden until the row is clicked. + expect(screen.queryByText(/"started": true/)).toBeNull(); + screen.getByTestId('audit-row-a-1').click(); + await waitFor(() => expect(screen.getByText(/"started": true/)).toBeTruthy()); + }); + + it('shows "system" actor when there is no actor, and "(deleted user)" when the id has no username', async () => { + vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({ + items: [ + entry({ id: 'a-sys', actor_user_id: null, actor_username: null }), + entry({ id: 'a-del', actor_user_id: 'u-x', actor_username: null }) + ], + page: { limit: 25, offset: 0, total: 2 } + }); + render(AuditTable); + await waitFor(() => expect(screen.getByText('system')).toBeTruthy()); + expect(screen.getByText('(deleted user)')).toBeTruthy(); + }); + + it('renders an empty state when there are no entries', async () => { + vi.spyOn(adminApi, 'listAuditLog').mockResolvedValue({ + items: [], + page: { limit: 25, offset: 0, total: 0 } + }); + render(AuditTable); + await waitFor(() => expect(screen.getByTestId('audit-empty')).toBeTruthy()); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index df66025..5ca2ca3 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -12,3 +12,17 @@ export function fmtDuration(ms: number | null | undefined): string { const rem = whole % 60; return `${mins}m ${String(rem).padStart(2, '0')}s`; } + +/** Relative "time ago" from an ISO timestamp, falling back to a locale date + * once it's more than a day old. `now` is injectable for tests. */ +export function fmtAgo(iso: string, now: number = Date.now()): string { + const then = new Date(iso).getTime(); + const secs = Math.round((now - then) / 1000); + if (secs < 45) return 'just now'; + if (secs < 90) return '1m ago'; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.round(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return new Date(iso).toLocaleDateString(); +} diff --git a/frontend/src/routes/admin/+layout.svelte b/frontend/src/routes/admin/+layout.svelte index 48d8aa0..fc9cb52 100644 --- a/frontend/src/routes/admin/+layout.svelte +++ b/frontend/src/routes/admin/+layout.svelte @@ -9,7 +9,8 @@ { href: '/admin/crawler', label: 'Crawler' }, { href: '/admin/analysis', label: 'Analysis' }, { href: '/admin/settings', label: 'Settings' }, - { href: '/admin/system', label: 'System' } + { href: '/admin/system', label: 'System' }, + { href: '/admin/audit', label: 'Audit' } ]; diff --git a/frontend/src/routes/admin/audit/+page.svelte b/frontend/src/routes/admin/audit/+page.svelte new file mode 100644 index 0000000..38781ca --- /dev/null +++ b/frontend/src/routes/admin/audit/+page.svelte @@ -0,0 +1,25 @@ + + +Audit log · Admin + +

Audit log

+

+ Every admin action — crawler runs, resyncs, settings changes, session + updates — is recorded here. Click a row to inspect its payload. +

+ + + +