feat(api): admin user management endpoints with audit log (0.38.0)
Adds /api/v1/admin/users list / DELETE / PATCH guarded by RequireAdmin, plus the audit-log substrate every future destructive admin endpoint will reuse. Safety properties: - Cannot self-delete or self-demote (409 conflict, message calls out "yourself" so the UI can render an explanation). - Cannot remove the last admin via either DELETE or demote. The check takes pg_advisory_xact_lock(ADMIN_INVARIANT_LOCK_KEY) and re-counts admins inside the same tx, closing the parallel-demote race that a bare "if count > 1" check would let through. The HTTP-serial path to this guard is structurally unreachable (the actor would have to be the lone admin demoting themselves, which the self-guard fires on first); the parallel race test exercises it via repo calls. Audit log (admin_audit table) records the action inside the same tx as the action itself, so a rolled-back action never leaves an orphan audit row. actor_user_id is ON DELETE SET NULL so the log outlives a later-deleted admin. target_id is not a FK because future audit kinds will target non-user rows.
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.36.7"
|
||||
version = "0.38.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
20
backend/migrations/0019_admin_audit.sql
Normal file
20
backend/migrations/0019_admin_audit.sql
Normal file
@@ -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);
|
||||
15
backend/src/api/admin/mod.rs
Normal file
15
backend/src/api/admin/mod.rs
Normal file
@@ -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<AppState> {
|
||||
Router::new().merge(users::routes())
|
||||
}
|
||||
92
backend/src/api/admin/users.rs
Normal file
92
backend/src/api/admin/users.rs
Normal file
@@ -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<AppState> {
|
||||
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<String>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
async fn list_users(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Query(params): Query<ListUsersParams>,
|
||||
) -> AppResult<Json<PagedResponse<User>>> {
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
async fn update_user(
|
||||
State(state): State<AppState>,
|
||||
RequireAdmin(actor): RequireAdmin,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(input): Json<UpdateUserInput>,
|
||||
) -> AppResult<Json<User>> {
|
||||
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<AppState>,
|
||||
RequireAdmin(actor): RequireAdmin,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<StatusCode> {
|
||||
repo::user::admin_safe_delete(&state.db, actor.id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod authors;
|
||||
pub mod bookmarks;
|
||||
@@ -28,4 +29,5 @@ pub fn routes() -> Router<AppState> {
|
||||
.merge(authors::routes())
|
||||
.merge(collections::routes())
|
||||
.merge(history::routes())
|
||||
.merge(admin::routes())
|
||||
}
|
||||
|
||||
15
backend/src/domain/admin_audit.rs
Normal file
15
backend/src/domain/admin_audit.rs
Normal file
@@ -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<Uuid>,
|
||||
pub action: String,
|
||||
pub target_kind: String,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
32
backend/src/repo/admin_audit.rs
Normal file
32
backend/src/repo/admin_audit.rs
Normal file
@@ -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<Uuid>,
|
||||
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(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod admin_audit;
|
||||
pub mod api_token;
|
||||
pub mod author;
|
||||
pub mod bookmark;
|
||||
|
||||
@@ -56,6 +56,64 @@ pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
|
||||
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<String>,
|
||||
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<User>, 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<User> {
|
||||
// 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<User> = 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<User> = 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,
|
||||
|
||||
368
backend/tests/api_admin_users.rs
Normal file
368
backend/tests/api_admin_users.rs
Normal file
@@ -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<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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user