Pairs with the ALLOW_SELF_REGISTER toggle from 0.42.0: admins can mint
accounts regardless of the toggle state, so a closed-membership
deployment still has a working enrollment path. The endpoint accepts
{ username, password, is_admin? } so admins can mint co-admins in one
call (avoiding a separate promote + extra audit row for the common
"invite a co-admin" flow).
Implementation:
- POST /api/v1/admin/users guarded by RequireAdmin
- Reuses validate_username / validate_password from api::auth (made
pub(crate)) so the admin path can never produce an account self-
register would reject and vice versa
- repo::user::admin_create_user wraps INSERT + admin_audit insert in
a single tx — same "audit reflects what committed" semantics as the
existing admin_safe_* fns
- Audit row: action="create_user", payload={username, is_admin}
Frontend:
- createAdminUser() in lib/api/admin.ts
- /admin/users grows a collapsible "Create user" form above the table
(username, password, "Make admin" checkbox). Errors surface inline;
the list reloads on success.
Backend tests: 7 new, including the headline
`create_user_works_even_when_self_register_disabled` that pins the
admin-create path is NOT gated by the public toggle.
606 lines
20 KiB
Rust
606 lines
20 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_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<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);
|
|
}
|
|
|
|
// ---- POST /admin/users (admin-create) --------------------------------------
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_requires_admin(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let (_username, cookie) = common::register_user(&h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_unauthenticated_is_rejected(pool: PgPool) {
|
|
let h = common::harness(pool);
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "newbie", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_happy_path_creates_user_and_audit(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, a_id) = seed_admin(&pool, &h.app).await;
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "invited01", "password": "freshpass1234" }),
|
|
&a_cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["username"], "invited01");
|
|
assert_eq!(body["is_admin"], false);
|
|
assert!(body["id"].as_str().is_some());
|
|
assert!(
|
|
body.get("password_hash").is_none(),
|
|
"password_hash must never appear in admin-create response"
|
|
);
|
|
|
|
let target_id =
|
|
Uuid::parse_str(body["id"].as_str().unwrap()).unwrap();
|
|
let (actor, action, kind, target, payload): (
|
|
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 ORDER BY at DESC LIMIT 1",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(actor, Some(a_id));
|
|
assert_eq!(action, "create_user");
|
|
assert_eq!(kind, "user");
|
|
assert_eq!(target, Some(target_id));
|
|
assert_eq!(payload["username"], "invited01");
|
|
assert_eq!(payload["is_admin"], false);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_can_mint_an_admin_in_one_call(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({
|
|
"username": "newadmin",
|
|
"password": "freshpass1234",
|
|
"is_admin": true
|
|
}),
|
|
&a_cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["is_admin"], true);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_returns_409_on_duplicate(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
|
// Seed an existing user via the public register path.
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/register",
|
|
json!({ "username": "taken", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "Taken", "password": "freshpass1234" }),
|
|
&a_cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::CONFLICT,
|
|
"case-insensitive collision via the lower(username) index"
|
|
);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "conflict");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_rejects_weak_password(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "okayname", "password": "short" }),
|
|
&a_cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
let body = common::body_json(resp).await;
|
|
assert_eq!(body["error"]["code"], "invalid_input");
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_rejects_invalid_username(pool: PgPool) {
|
|
let h = common::harness(pool.clone());
|
|
let (_a_name, a_cookie, _) = seed_admin(&pool, &h.app).await;
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "bad name!", "password": "freshpass1234" }),
|
|
&a_cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[sqlx::test(migrations = "./migrations")]
|
|
async fn create_user_works_even_when_self_register_disabled(pool: PgPool) {
|
|
// The admin-create path must NOT be gated by ALLOW_SELF_REGISTER —
|
|
// that's the entire point of having an admin-create endpoint.
|
|
let h = common::harness_with_self_register_disabled(pool.clone());
|
|
// Bootstrap an admin out-of-band since self-register would refuse.
|
|
repo::user::bootstrap_admin(&pool, "root", "hunter2hunter2")
|
|
.await
|
|
.unwrap();
|
|
let resp = h
|
|
.app
|
|
.clone()
|
|
.oneshot(common::post_json(
|
|
"/api/v1/auth/login",
|
|
json!({ "username": "root", "password": "hunter2hunter2" }),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let cookie = common::extract_session_cookie(&resp).unwrap();
|
|
|
|
let resp = h
|
|
.app
|
|
.oneshot(common::post_json_with_cookie(
|
|
"/api/v1/admin/users",
|
|
json!({ "username": "invited01", "password": "freshpass1234" }),
|
|
&cookie,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::CREATED,
|
|
"admin must be able to mint users even with self-register off"
|
|
);
|
|
}
|