use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use umbral_auth::{
AuthError, AuthPlugin, AuthUser, authenticate, create_user, hash_password, set_password,
verify_password,
};
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings =
umbral::Settings::from_env().expect("figment defaults always load in a test env");
let tmp = tempfile::tempdir().expect("create tempdir for the test DB");
let db_path = tmp.path().join("umbral_auth_integration.sqlite");
std::mem::forget(tmp);
let options = SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
.expect("sqlite should connect against the tempfile");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<AuthUser>::default())
.build()
.expect("App::build should succeed with AuthPlugin");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE auth_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_active INTEGER NOT NULL,
is_staff INTEGER NOT NULL,
is_superuser INTEGER NOT NULL,
date_joined TEXT NOT NULL,
last_login TEXT
)",
)
.execute(&pool)
.await
.expect("create auth_user table");
})
.await;
}
fn pool() -> sqlx::SqlitePool {
umbral::db::pool()
}
#[test]
fn hash_password_produces_different_hashes_for_same_plaintext() {
let a = hash_password("hunter2").expect("argon2 should hash without erroring");
let b = hash_password("hunter2").expect("argon2 should hash without erroring");
assert_ne!(
a, b,
"argon2 must salt every call, but two hashes of `hunter2` matched: {a}",
);
}
#[test]
fn verify_password_round_trip() {
let hash = hash_password("hunter2").expect("argon2 should hash without erroring");
assert!(
verify_password("hunter2", &hash).expect("verify against valid hash should not error"),
"the same plaintext should verify against its own hash",
);
assert!(
!verify_password("wrong", &hash).expect("verify against valid hash should not error"),
"a wrong plaintext should return Ok(false), not match",
);
let bad = verify_password("hunter2", "not-a-phc-string");
assert!(
matches!(bad, Err(AuthError::PasswordHash(_))),
"a malformed hash should surface as AuthError::PasswordHash; got {bad:?}",
);
}
#[tokio::test]
async fn create_user_writes_to_the_database() {
boot().await;
let user = create_user("alice", "alice@example.com", "Tr0ub4dour&3xpl")
.await
.expect("create_user should succeed against the fresh auth_user table");
assert_eq!(user.username, "alice");
assert_eq!(user.email, "alice@example.com");
assert_ne!(
user.password_hash, "hunter2",
"the stored hash must not equal the plaintext password",
);
assert!(
!user.password_hash.is_empty(),
"password_hash should be populated"
);
assert!(user.is_active, "new users default to is_active = true");
assert!(!user.is_staff, "new users default to is_staff = false");
assert!(
!user.is_superuser,
"new users default to is_superuser = false"
);
assert!(user.last_login.is_none(), "new users have no last_login");
let row: (String, String, i64, i64, i64) = sqlx::query_as(
"SELECT username, email, is_active, is_staff, is_superuser FROM auth_user WHERE username = ?",
)
.bind("alice")
.fetch_one(&pool())
.await
.expect("the alice row should exist after create_user");
assert_eq!(row.0, "alice");
assert_eq!(row.1, "alice@example.com");
assert_eq!(row.2, 1, "is_active should serialize to 1 in SQLite");
assert_eq!(row.3, 0, "is_staff should serialize to 0 in SQLite");
assert_eq!(row.4, 0, "is_superuser should serialize to 0 in SQLite");
}
#[tokio::test]
async fn authenticate_returns_the_user_for_valid_credentials() {
boot().await;
let created = create_user("bob", "bob@example.com", "Zephyr!Qu14-Knight")
.await
.expect("create_user should succeed for bob");
let found = authenticate::<AuthUser>("bob", "Zephyr!Qu14-Knight")
.await
.expect("authenticate should succeed for matching credentials");
assert_eq!(
found.id, created.id,
"authenticate should return the same row"
);
assert_eq!(found.username, "bob");
assert_eq!(found.email, "bob@example.com");
}
#[tokio::test]
async fn authenticate_rejects_wrong_password() {
boot().await;
create_user("carol", "carol@example.com", "rightpass")
.await
.expect("create_user should succeed for carol");
let result = authenticate::<AuthUser>("carol", "wrongpass").await;
assert!(
matches!(result, Err(AuthError::InvalidCredentials)),
"wrong password must surface as InvalidCredentials; got {result:?}",
);
}
#[tokio::test]
async fn authenticate_rejects_unknown_username() {
boot().await;
let result = authenticate::<AuthUser>("ghost", "anything").await;
assert!(
matches!(result, Err(AuthError::InvalidCredentials)),
"unknown username must surface as InvalidCredentials; got {result:?}",
);
}
#[tokio::test]
async fn authenticate_rejects_inactive_user() {
boot().await;
create_user("dave", "dave@example.com", "Br1ghtMoon#0723")
.await
.expect("create_user should succeed for dave");
sqlx::query("UPDATE auth_user SET is_active = 0 WHERE username = ?")
.bind("dave")
.execute(&pool())
.await
.expect("deactivation update should succeed");
let result = authenticate::<AuthUser>("dave", "Br1ghtMoon#0723").await;
assert!(
matches!(result, Err(AuthError::InvalidCredentials)),
"an inactive user must not authenticate; got {result:?}",
);
}
#[tokio::test]
async fn set_password_updates_the_hash() {
boot().await;
let mut user = create_user("erin", "erin@example.com", "Stout$Wombat-58")
.await
.expect("create_user should succeed for erin");
let original_hash = user.password_hash.clone();
set_password(&mut user, "Clay#Harbor-90")
.await
.expect("set_password should rotate the hash");
assert_ne!(
user.password_hash, original_hash,
"set_password must update the in-place hash, but it stayed {original_hash}",
);
authenticate::<AuthUser>("erin", "Clay#Harbor-90")
.await
.expect("the new password must authenticate after set_password");
let stale = authenticate::<AuthUser>("erin", "Stout$Wombat-58").await;
assert!(
matches!(stale, Err(AuthError::InvalidCredentials)),
"the old password must stop working after set_password; got {stale:?}",
);
}
#[tokio::test]
async fn auth_plugin_registers_the_authuser_model() {
boot().await;
let models = umbral::migrate::models_for_plugin("auth");
let tables: Vec<&str> = models.iter().map(|m| m.table.as_str()).collect();
assert!(
tables.contains(&"auth_user"),
"AuthPlugin must register auth_user; got {tables:?}",
);
assert!(
tables.contains(&"auth_token"),
"AuthPlugin must register auth_token alongside auth_user; got {tables:?}",
);
assert_eq!(
models.len(),
2,
"AuthPlugin contributes exactly two models (auth_user + auth_token); got {models:?}",
);
let _from_user: umbral::migrate::ModelMeta = umbral::migrate::ModelMeta::for_::<AuthUser>();
let _from_token: umbral::migrate::ModelMeta =
umbral::migrate::ModelMeta::for_::<umbral_auth::AuthToken>();
}
static SUPERUSER_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[tokio::test]
async fn dispatch_routes_createsuperuser_command_with_noinput() {
boot().await;
let _env_guard = SUPERUSER_ENV_LOCK.lock().await;
let pool = umbral::db::pool();
sqlx::query("DELETE FROM auth_user WHERE username = 'admin'")
.execute(&pool)
.await
.expect("clean slate");
unsafe {
std::env::set_var("UMBRAL_SUPERUSER_PASSWORD", "swordfish-9-9");
}
let plugins: Vec<Box<dyn umbral::prelude::Plugin>> = vec![Box::new(umbral_auth::AuthPlugin::<
umbral_auth::AuthUser,
>::default())];
let outcome = umbral::cli::dispatch(
&plugins,
vec![
"umbral-cli",
"createsuperuser",
"--username",
"admin",
"--email",
"admin@example.com",
"--noinput",
],
)
.await
.expect("dispatch ok");
match outcome {
umbral::cli::DispatchOutcome::Matched(name) => {
assert_eq!(name, "createsuperuser");
}
other => panic!("expected Matched(createsuperuser); got {other:?}"),
}
unsafe {
std::env::remove_var("UMBRAL_SUPERUSER_PASSWORD");
}
let user = umbral_auth::authenticate::<umbral_auth::AuthUser>("admin", "swordfish-9-9")
.await
.expect("authenticate");
assert_eq!(user.username, "admin");
assert_eq!(user.email, "admin@example.com");
assert!(user.is_staff, "createsuperuser must set is_staff = true");
assert!(
user.is_superuser,
"createsuperuser must set is_superuser = true"
);
assert!(user.is_active, "the new user should be active");
}
#[tokio::test]
async fn createsuperuser_noinput_errors_without_password_env() {
boot().await;
let _env_guard = SUPERUSER_ENV_LOCK.lock().await;
unsafe {
std::env::remove_var("UMBRAL_SUPERUSER_PASSWORD");
}
let plugins: Vec<Box<dyn umbral::prelude::Plugin>> = vec![Box::new(umbral_auth::AuthPlugin::<
umbral_auth::AuthUser,
>::default())];
let result = umbral::cli::dispatch(
&plugins,
vec![
"umbral-cli",
"createsuperuser",
"--username",
"ghost",
"--email",
"ghost@example.com",
"--noinput",
],
)
.await;
let err = result.expect_err("dispatch should err when password isn't supplied");
let msg = err.to_string();
assert!(
msg.contains("password not provided") || msg.contains("UMBRAL_SUPERUSER_PASSWORD"),
"expected password-missing error; got: {msg}"
);
}