diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e3975cc..0f41c7b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.36.7" +version = "0.38.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a0fa23c..ae3c72e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.37.0" +version = "0.38.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0019_admin_audit.sql b/backend/migrations/0019_admin_audit.sql new file mode 100644 index 0000000..10ff65b --- /dev/null +++ b/backend/migrations/0019_admin_audit.sql @@ -0,0 +1,20 @@ +-- Admin audit log. Written from inside the same transaction as the action +-- it records, so a failed COMMIT also rolls back the audit row — the log +-- never claims an action happened that didn't. +-- +-- `actor_user_id` is ON DELETE SET NULL so audit rows outlive a deleted +-- admin (the answer to "who promoted Bob to admin?" survives even after +-- Alice's account is removed). `target_id` is intentionally not a FK +-- because future audit kinds may target non-user rows (manga, source, +-- etc.) and a single typed FK can't express that. +CREATE TABLE admin_audit ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL, + action text NOT NULL, + target_kind text NOT NULL, + target_id uuid, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX admin_audit_at_idx ON admin_audit (at DESC); diff --git a/backend/src/api/admin/mod.rs b/backend/src/api/admin/mod.rs new file mode 100644 index 0000000..d6c126d --- /dev/null +++ b/backend/src/api/admin/mod.rs @@ -0,0 +1,15 @@ +//! Admin-only endpoints. Mounted under `/api/v1/admin/*` by +//! `crate::api::routes`. Every handler in this subtree is guarded by +//! `RequireAdmin`, which only accepts session-cookie authentication — +//! bot/API tokens cannot reach admin routes (see +//! `crate::auth::extractor::RequireAdmin`). + +pub mod users; + +use axum::Router; + +use crate::app::AppState; + +pub fn routes() -> Router { + Router::new().merge(users::routes()) +} diff --git a/backend/src/api/admin/users.rs b/backend/src/api/admin/users.rs new file mode 100644 index 0000000..831152c --- /dev/null +++ b/backend/src/api/admin/users.rs @@ -0,0 +1,92 @@ +//! Admin user management: list, delete, promote/demote. +//! +//! All handlers are gated by `RequireAdmin` and rely on +//! `repo::user::admin_safe_*` for self-protection and the last-admin +//! invariant. Audit rows are written inside the same DB transaction as +//! the action they record. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get}; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::api::pagination::PagedResponse; +use crate::app::AppState; +use crate::auth::extractor::RequireAdmin; +use crate::domain::User; +use crate::error::{AppError, AppResult}; +use crate::repo; + +pub fn routes() -> Router { + Router::new() + .route("/admin/users", get(list_users)) + .route( + "/admin/users/:id", + delete(delete_user).patch(update_user), + ) +} + +#[derive(Debug, Deserialize, Default)] +pub struct ListUsersParams { + #[serde(default)] + pub search: Option, + #[serde(default = "default_limit")] + pub limit: i64, + #[serde(default)] + pub offset: i64, +} + +fn default_limit() -> i64 { + 50 +} + +async fn list_users( + State(state): State, + _admin: RequireAdmin, + Query(params): Query, +) -> AppResult>> { + let limit = params.limit.clamp(1, 200); + let offset = params.offset.max(0); + let (items, total) = repo::user::list_with_total( + &state.db, + &repo::user::ListUsersQuery { + search: params.search.filter(|s| !s.trim().is_empty()), + limit, + offset, + }, + ) + .await?; + Ok(Json(PagedResponse::with_total(items, limit, offset, total))) +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUserInput { + pub is_admin: Option, +} + +async fn update_user( + State(state): State, + RequireAdmin(actor): RequireAdmin, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + let Some(is_admin) = input.is_admin else { + return Err(AppError::InvalidInput( + "no updatable fields supplied".into(), + )); + }; + let updated = + repo::user::admin_safe_set_is_admin(&state.db, actor.id, id, is_admin).await?; + Ok(Json(updated)) +} + +async fn delete_user( + State(state): State, + RequireAdmin(actor): RequireAdmin, + Path(id): Path, +) -> AppResult { + repo::user::admin_safe_delete(&state.db, actor.id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 64bbc33..d8a29f3 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod auth; pub mod authors; pub mod bookmarks; @@ -28,4 +29,5 @@ pub fn routes() -> Router { .merge(authors::routes()) .merge(collections::routes()) .merge(history::routes()) + .merge(admin::routes()) } diff --git a/backend/src/domain/admin_audit.rs b/backend/src/domain/admin_audit.rs new file mode 100644 index 0000000..bc8b9ab --- /dev/null +++ b/backend/src/domain/admin_audit.rs @@ -0,0 +1,15 @@ +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::FromRow; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, FromRow)] +pub struct AdminAuditEntry { + pub id: Uuid, + pub actor_user_id: Option, + pub action: String, + pub target_kind: String, + pub target_id: Option, + pub payload: serde_json::Value, + pub at: DateTime, +} diff --git a/backend/src/domain/mod.rs b/backend/src/domain/mod.rs index 15fdc81..b7782d4 100644 --- a/backend/src/domain/mod.rs +++ b/backend/src/domain/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_audit; pub mod api_token; pub mod author; pub mod bookmark; @@ -14,6 +15,7 @@ pub mod upload_entry; pub mod user; pub mod user_preferences; +pub use admin_audit::AdminAuditEntry; pub use api_token::ApiToken; pub use author::{Author, AuthorRef, AuthorWithCount}; pub use bookmark::{Bookmark, BookmarkSummary}; diff --git a/backend/src/repo/admin_audit.rs b/backend/src/repo/admin_audit.rs new file mode 100644 index 0000000..a29261e --- /dev/null +++ b/backend/src/repo/admin_audit.rs @@ -0,0 +1,32 @@ +//! Admin-action audit log writes. +//! +//! 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. + +use sqlx::PgExecutor; +use uuid::Uuid; + +use crate::error::AppResult; + +pub async fn insert<'e, E: PgExecutor<'e>>( + executor: E, + actor_user_id: Uuid, + action: &str, + target_kind: &str, + target_id: Option, + payload: serde_json::Value, +) -> AppResult<()> { + sqlx::query( + "INSERT INTO admin_audit (actor_user_id, action, target_kind, target_id, payload) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(actor_user_id) + .bind(action) + .bind(target_kind) + .bind(target_id) + .bind(payload) + .execute(executor) + .await?; + Ok(()) +} diff --git a/backend/src/repo/mod.rs b/backend/src/repo/mod.rs index d179484..bc0ae3f 100644 --- a/backend/src/repo/mod.rs +++ b/backend/src/repo/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_audit; pub mod api_token; pub mod author; pub mod bookmark; diff --git a/backend/src/repo/user.rs b/backend/src/repo/user.rs index 630c71a..6ee8900 100644 --- a/backend/src/repo/user.rs +++ b/backend/src/repo/user.rs @@ -56,6 +56,64 @@ pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult> { Ok(row) } +/// Postgres advisory-lock key guarding admin-count-changing operations +/// (demote, delete-admin). Without this lock two concurrent demotes of +/// different admins could each pass their "more than one admin remains" +/// check, then commit, leaving zero admins. The lock serialises any tx +/// that might change the admin count so the recount under the lock is +/// authoritative. +/// +/// Value is the bytes of "admininv" interpreted as a big-endian i64. +/// Postgres' advisory-lock keyspace is global; collision risk with +/// `CRON_LOCK_KEY` and friends is ~2^-64. +pub const ADMIN_INVARIANT_LOCK_KEY: i64 = 0x61_64_6d_69_6e_69_6e_76; + +#[derive(Debug, Default)] +pub struct ListUsersQuery { + pub search: Option, + pub limit: i64, + pub offset: i64, +} + +/// Paginated user list with total count. `search` is a case-insensitive +/// substring match on `username`. Order is alphabetical by username so +/// pagination is stable across concurrent writes (mangas changing +/// is_admin doesn't reshuffle the page). +pub async fn list_with_total( + pool: &PgPool, + q: &ListUsersQuery, +) -> AppResult<(Vec, i64)> { + let pat = q + .search + .as_ref() + .map(|s| format!("%{}%", s.trim())) + .filter(|p| p.len() > 2); + + let items = sqlx::query_as::<_, User>( + r#" + SELECT id, username, password_hash, created_at, is_admin + FROM users + WHERE ($1::text IS NULL OR username ILIKE $1) + ORDER BY username + LIMIT $2 OFFSET $3 + "#, + ) + .bind(&pat) + .bind(q.limit) + .bind(q.offset) + .fetch_all(pool) + .await?; + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE ($1::text IS NULL OR username ILIKE $1)", + ) + .bind(&pat) + .fetch_one(pool) + .await?; + + Ok((items, total)) +} + pub async fn set_is_admin(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> { sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2") .bind(value) @@ -76,6 +134,148 @@ pub async fn set_is_admin(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> /// admin password through the UI without env-var conflict. /// Wrapped in a transaction so a concurrent `register` for the same /// username can't slip an INSERT between the SELECT and UPDATE/INSERT. +/// Set `is_admin` on a user with full safety checks: rejects self-demote, +/// rejects demoting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY` +/// to close the parallel-demote race), and writes an `admin_audit` row +/// in the same tx so the log mirrors what actually committed. +/// +/// Returns the freshly-written user row (so the handler can return it +/// without a second SELECT). +pub async fn admin_safe_set_is_admin( + pool: &PgPool, + actor_id: Uuid, + target_id: Uuid, + value: bool, +) -> AppResult { + // Cheap pre-check before opening a tx — also covers the "demote me" + // case which would otherwise pass the recount when other admins exist. + if actor_id == target_id && !value { + return Err(AppError::Conflict( + "cannot demote yourself; ask another admin".into(), + )); + } + + let mut tx = pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(ADMIN_INVARIANT_LOCK_KEY) + .execute(&mut *tx) + .await?; + + let target: Option = sqlx::query_as( + "SELECT id, username, password_hash, created_at, is_admin \ + FROM users WHERE id = $1 FOR UPDATE", + ) + .bind(target_id) + .fetch_optional(&mut *tx) + .await?; + let Some(target) = target else { + return Err(AppError::NotFound); + }; + + // Recount inside the lock — this is the authoritative read. + if target.is_admin && !value { + let admin_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true") + .fetch_one(&mut *tx) + .await?; + if admin_count <= 1 { + return Err(AppError::Conflict( + "cannot demote the last admin; promote another user first".into(), + )); + } + } + + let updated: User = sqlx::query_as( + "UPDATE users SET is_admin = $1 WHERE id = $2 \ + RETURNING id, username, password_hash, created_at, is_admin", + ) + .bind(value) + .bind(target_id) + .fetch_one(&mut *tx) + .await?; + + let action = if value { "promote_user" } else { "demote_user" }; + crate::repo::admin_audit::insert( + &mut *tx, + actor_id, + action, + "user", + Some(target_id), + serde_json::json!({ "username": target.username }), + ) + .await?; + + tx.commit().await?; + Ok(updated) +} + +/// Delete a user with full safety checks: rejects self-delete, rejects +/// deleting the only remaining admin (under `ADMIN_INVARIANT_LOCK_KEY`), +/// and writes an `admin_audit` row in the same tx. Captures the deleted +/// username + admin status in the audit payload so the action is +/// readable after the user row itself is gone. +pub async fn admin_safe_delete( + pool: &PgPool, + actor_id: Uuid, + target_id: Uuid, +) -> AppResult<()> { + if actor_id == target_id { + return Err(AppError::Conflict( + "cannot delete yourself; ask another admin".into(), + )); + } + + let mut tx = pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(ADMIN_INVARIANT_LOCK_KEY) + .execute(&mut *tx) + .await?; + + let target: Option = sqlx::query_as( + "SELECT id, username, password_hash, created_at, is_admin \ + FROM users WHERE id = $1 FOR UPDATE", + ) + .bind(target_id) + .fetch_optional(&mut *tx) + .await?; + let Some(target) = target else { + return Err(AppError::NotFound); + }; + + if target.is_admin { + let admin_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE is_admin = true") + .fetch_one(&mut *tx) + .await?; + if admin_count <= 1 { + return Err(AppError::Conflict( + "cannot delete the last admin; promote another user first".into(), + )); + } + } + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(target_id) + .execute(&mut *tx) + .await?; + + crate::repo::admin_audit::insert( + &mut *tx, + actor_id, + "delete_user", + "user", + Some(target_id), + serde_json::json!({ + "username": target.username, + "was_admin": target.is_admin, + }), + ) + .await?; + + tx.commit().await?; + Ok(()) +} + pub async fn bootstrap_admin( pool: &PgPool, username: &str, diff --git a/backend/tests/api_admin_users.rs b/backend/tests/api_admin_users.rs new file mode 100644 index 0000000..4743228 --- /dev/null +++ b/backend/tests/api_admin_users.rs @@ -0,0 +1,368 @@ +//! PR 2 (feat/admin-users-api) integration tests. +//! +//! Exercises list / delete / promote-demote on /api/v1/admin/users: +//! pagination + search, the RequireAdmin gate, self-protection, +//! last-admin invariant (including the parallel-demote race that +//! `pg_advisory_xact_lock` + recount-inside-tx guards against), and +//! that audit rows land in `admin_audit` only on successful commit. +//! +//! Note on the last-admin invariant: the *serial* path via HTTP is +//! structurally unreachable — the only configuration that would hit the +//! "would orphan admins" branch requires the actor to be the lone admin +//! demoting themselves, which the self-guard fires on first. So the +//! last-admin checks below call the repo directly to exercise the +//! invariant; the HTTP race scenario is covered by +//! `parallel_demotes_cannot_orphan_admins`. + +mod common; + +use axum::http::StatusCode; +use axum::Router; +use serde_json::json; +use sqlx::PgPool; +use tower::ServiceExt; +use uuid::Uuid; + +use mangalord::error::AppError; +use mangalord::repo; + +/// Register a user via the public API and immediately promote them via +/// the repo. Returns (username, session cookie, user_id) — the common +/// "I need a logged-in admin" prelude. +async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String, Uuid) { + let (username, cookie) = common::register_user(app).await; + let u = repo::user::find_by_username(pool, &username) + .await + .unwrap() + .unwrap(); + repo::user::set_is_admin(pool, u.id, true).await.unwrap(); + (username, cookie, u.id) +} + +// ---- RequireAdmin gate ----------------------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn list_requires_admin(pool: PgPool) { + let h = common::harness(pool); + let (_username, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::get_with_cookie("/api/v1/admin/users", &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_requires_admin(pool: PgPool) { + let h = common::harness(pool); + let (_username, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/admin/users/{}", Uuid::new_v4()), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[sqlx::test(migrations = "./migrations")] +async fn patch_requires_admin(pool: PgPool) { + let h = common::harness(pool); + let (_username, cookie) = common::register_user(&h.app).await; + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/admin/users/{}", Uuid::new_v4()), + json!({ "is_admin": true }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// ---- list with search and pagination --------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn list_returns_paginated_users(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await; + let _u1 = common::register_user(&h.app).await; + let _u2 = common::register_user(&h.app).await; + let _u3 = common::register_user(&h.app).await; + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/admin/users?limit=2&offset=0", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let items = body["items"].as_array().expect("items array"); + assert_eq!(items.len(), 2, "limit=2 should cap the page"); + assert_eq!(body["page"]["limit"], 2); + assert_eq!(body["page"]["offset"], 0); + assert_eq!(body["page"]["total"], 4); + assert!(items[0].get("is_admin").is_some()); + assert!( + items[0].get("password_hash").is_none(), + "password_hash must never leak even to other admins" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn list_filters_by_substring_search(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_admin_name, cookie, _) = seed_admin(&pool, &h.app).await; + let resp = h + .app + .clone() + .oneshot(common::post_json( + "/api/v1/auth/register", + json!({ "username": "zzzfindme01", "password": "hunter2hunter2" }), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + + let resp = h + .app + .oneshot(common::get_with_cookie( + "/api/v1/admin/users?search=zzzfindme", + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + let items = body["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "search must narrow to the one match"); + assert_eq!(items[0]["username"], "zzzfindme01"); + assert_eq!(body["page"]["total"], 1); +} + +// ---- self-protection ------------------------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn cannot_self_delete(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_username, cookie, actor_id) = seed_admin(&pool, &h.app).await; + // Second admin so the last-admin guard isn't what triggers the conflict. + let (_other, _, _) = seed_admin(&pool, &h.app).await; + + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/admin/users/{actor_id}"), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "conflict"); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("yourself"), + "message must call out the self-action; got {:?}", + body["error"]["message"] + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn cannot_self_demote(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_username, cookie, actor_id) = seed_admin(&pool, &h.app).await; + let (_other, _, _) = seed_admin(&pool, &h.app).await; + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/admin/users/{actor_id}"), + json!({ "is_admin": false }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + let body = common::body_json(resp).await; + assert!(body["error"]["message"] + .as_str() + .unwrap() + .contains("yourself")); +} + +// ---- last-admin invariant (repo layer, see file header) -------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn last_admin_demote_refused_at_repo(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_a, _, a_id) = seed_admin(&pool, &h.app).await; + let (_b, _, b_id) = seed_admin(&pool, &h.app).await; + + // admins = {A, B}. Demote A via B (count 2 → 1) — allowed. + let r = repo::user::admin_safe_set_is_admin(&pool, b_id, a_id, false) + .await + .expect("first demote succeeds"); + assert!(!r.is_admin); + + // admins = {B}. Try to demote B via A (actor doesn't matter to the + // repo — that's the HTTP gate's job). Last-admin guard kicks in. + let err = repo::user::admin_safe_set_is_admin(&pool, a_id, b_id, false) + .await + .expect_err("second demote must be refused"); + match err { + AppError::Conflict(m) => assert!( + m.contains("last admin"), + "expected last-admin conflict; got {m:?}" + ), + other => panic!("expected Conflict, got {other:?}"), + } +} + +#[sqlx::test(migrations = "./migrations")] +async fn last_admin_delete_refused_at_repo(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_a, _, a_id) = seed_admin(&pool, &h.app).await; + let (_b, _, b_id) = seed_admin(&pool, &h.app).await; + + // admins = {A, B}. Delete A via B (count 2 → 1) — allowed. + repo::user::admin_safe_delete(&pool, b_id, a_id) + .await + .expect("first delete succeeds"); + + // admins = {B}. Try to delete B via a fresh non-admin actor. Last- + // admin guard kicks in. + let (_c, _, c_id) = { + let (cn, _ck) = common::register_user(&h.app).await; + let c = repo::user::find_by_username(&pool, &cn).await.unwrap().unwrap(); + (cn, _ck, c.id) + }; + let err = repo::user::admin_safe_delete(&pool, c_id, b_id) + .await + .expect_err("second delete must be refused"); + match err { + AppError::Conflict(m) => assert!( + m.contains("last admin"), + "expected last-admin conflict; got {m:?}" + ), + other => panic!("expected Conflict, got {other:?}"), + } +} + +#[sqlx::test(migrations = "./migrations")] +async fn parallel_demotes_cannot_orphan_admins(pool: PgPool) { + // The race the advisory lock + recount exists to close: two parallel + // demotes of two DIFFERENT admins, each reading `count = 2` and + // committing, would land at zero admins. With the lock the second + // demote sees count = 1 inside the tx and refuses. + let h = common::harness(pool.clone()); + let (_a, _, a_id) = seed_admin(&pool, &h.app).await; + let (_b, _, b_id) = seed_admin(&pool, &h.app).await; + + let pool_x = pool.clone(); + let pool_y = pool.clone(); + let task_x = tokio::spawn(async move { + repo::user::admin_safe_set_is_admin(&pool_x, a_id, b_id, false).await + }); + let task_y = tokio::spawn(async move { + repo::user::admin_safe_set_is_admin(&pool_y, b_id, a_id, false).await + }); + let r_x = task_x.await.unwrap(); + let r_y = task_y.await.unwrap(); + + let outcomes = (r_x.is_ok(), r_y.is_ok()); + assert!( + outcomes == (true, false) || outcomes == (false, true), + "exactly one of the two parallel demotes must succeed; got {outcomes:?}" + ); + + let (count,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE is_admin = true") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1, "at least one admin must remain"); +} + +// ---- audit log ------------------------------------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn promote_writes_audit_row(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await; + let (b_name, _b_cookie) = common::register_user(&h.app).await; + let b = repo::user::find_by_username(&pool, &b_name) + .await + .unwrap() + .unwrap(); + + let resp = h + .app + .oneshot(common::patch_json_with_cookie( + &format!("/api/v1/admin/users/{}", b.id), + json!({ "is_admin": true }), + &a_cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let rows: Vec<(Option, String, String, Option)> = sqlx::query_as( + "SELECT actor_user_id, action, target_kind, target_id FROM admin_audit", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + let (actor, action, kind, target) = rows.into_iter().next().unwrap(); + assert_eq!(actor, Some(a_id)); + assert_eq!(action, "promote_user"); + assert_eq!(kind, "user"); + assert_eq!(target, Some(b.id)); +} + +#[sqlx::test(migrations = "./migrations")] +async fn delete_writes_audit_row(pool: PgPool) { + let h = common::harness(pool.clone()); + let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await; + let (b_name, _b_cookie) = common::register_user(&h.app).await; + let b = repo::user::find_by_username(&pool, &b_name) + .await + .unwrap() + .unwrap(); + + let resp = h + .app + .oneshot(common::delete_with_cookie( + &format!("/api/v1/admin/users/{}", b.id), + &a_cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let rows: Vec<(Option, String, String, Option, serde_json::Value)> = + sqlx::query_as( + "SELECT actor_user_id, action, target_kind, target_id, payload FROM admin_audit", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + let (actor, action, kind, target, payload) = rows.into_iter().next().unwrap(); + assert_eq!(actor, Some(a_id)); + assert_eq!(action, "delete_user"); + assert_eq!(kind, "user"); + assert_eq!(target, Some(b.id)); + assert_eq!(payload["username"], b_name); + assert_eq!(payload["was_admin"], false); +} diff --git a/frontend/package.json b/frontend/package.json index b816797..d2cd04b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.37.0", + "version": "0.38.0", "private": true, "type": "module", "scripts": {