//! 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_unchecked(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 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, 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); }