- admin_safe_set_is_admin: short-circuit when target.is_admin == value,
before writing audit. PATCH {is_admin: true} on someone already admin
previously wrote a misleading "promote_user" row even though the UPDATE
was a no-op.
- list_chapters (/admin/mangas/:id/chapters): explicit exists() check on
manga_id, returns 404 instead of 200 [] for a typo'd / deleted manga.
- ChapterSyncState priority: the Failed branch now requires page_count = 0,
so a chapter with pages on disk AND a historical dead job (from a
re-download attempt that crashed) stays Synced. The old order
contradicted Synced's documented "downloaded at some point" contract.
Doc comments updated alongside the SQL.
Three new regression tests pin the behaviour.
400 lines
14 KiB
Rust
400 lines
14 KiB
Rust
//! 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<Uuid>, String, String, Option<Uuid>)> = 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 redundant_promote_does_not_write_audit_row(pool: PgPool) {
|
|
// Regression: PATCH {is_admin: true} on someone already admin used
|
|
// to UPDATE (no-op) and still INSERT a misleading "promote_user"
|
|
// audit row. Should short-circuit without touching admin_audit.
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, _a_id) = seed_admin(&pool, &h.app).await;
|
|
let (b_name, _b_cookie, _b_id) = seed_admin(&pool, &h.app).await; // already admin
|
|
|
|
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 (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM admin_audit")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(count, 0, "no-op promote must not write audit row");
|
|
}
|
|
|
|
#[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<Uuid>, String, String, Option<Uuid>, 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);
|
|
}
|