use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use umbral_auth::{
AuthError, AuthPlugin, AuthUser, authenticate, create_user, hash_password,
random_password_hash, 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()
.busy_timeout(std::time::Duration::from_secs(5))
.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,
email_verified_at 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!(
tables.contains(&"auth_challenge"),
"AuthPlugin must register auth_challenge alongside auth_user; got {tables:?}",
);
assert_eq!(
models.len(),
3,
"AuthPlugin contributes exactly three models (auth_user + auth_token + auth_challenge); 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>();
let _from_challenge: umbral::migrate::ModelMeta =
umbral::migrate::ModelMeta::for_::<umbral_auth::AuthChallenge>();
}
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}"
);
}
#[tokio::test]
async fn create_user_normalizes_username_and_email_to_lowercase() {
boot().await;
let user = create_user(
" MixedCase_Dave ",
"Dave@Example.COM",
"Tr0ub4dour&3xpl-dave",
)
.await
.expect("create_user should succeed");
assert_eq!(
user.username, "mixedcase_dave",
"username is trimmed + lowercased on the returned struct"
);
assert_eq!(
user.email, "dave@example.com",
"email is trimmed + lowercased on the returned struct"
);
let row: (String, String) =
sqlx::query_as("SELECT username, email FROM auth_user WHERE username = ?")
.bind("mixedcase_dave")
.fetch_one(&pool())
.await
.expect("row stored under the lowercased username");
assert_eq!(row.0, "mixedcase_dave");
assert_eq!(row.1, "dave@example.com");
}
#[tokio::test]
async fn duplicate_signup_differing_only_by_case_is_rejected() {
boot().await;
create_user(
"dalmasonto",
"dalmasogembo@gmail.com",
"Zephyr!Qu14-Knight-1",
)
.await
.expect("first signup succeeds");
let dup = create_user(
"Dalmasonto",
"Dalmasogembo@Gmail.com",
"Zephyr!Qu14-Knight-2",
)
.await;
assert!(
matches!(
dup,
Err(AuthError::Write(
umbral::orm::write::WriteError::UniqueViolation { .. }
))
),
"a case-only-different re-signup must be rejected as a uniqueness violation; got {dup:?}"
);
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM auth_user WHERE username = 'dalmasonto' OR username = 'Dalmasonto'",
)
.fetch_one(&pool())
.await
.expect("count query");
assert_eq!(count, 1, "only the canonical row exists");
}
#[tokio::test]
async fn authenticate_is_case_insensitive_on_username() {
boot().await;
let created = create_user("caseuser", "caseuser@example.com", "R1ght-Pass-Phrase!")
.await
.expect("create_user succeeds");
let found = authenticate::<AuthUser>("CaseUser", "R1ght-Pass-Phrase!")
.await
.expect("authenticate must match case-insensitively");
assert_eq!(found.id, created.id, "same row found regardless of case");
assert_eq!(found.username, "caseuser");
let trimmed = authenticate::<AuthUser>(" caseuser ", "R1ght-Pass-Phrase!")
.await
.expect("authenticate trims the typed username");
assert_eq!(trimmed.id, created.id);
}
#[tokio::test]
async fn authenticate_accepts_either_username_or_email() {
boot().await;
let created = create_user("loginboth", "loginboth@example.com", "R1ght-Pass-Phrase!")
.await
.expect("create_user succeeds");
let by_username = authenticate::<AuthUser>("loginboth", "R1ght-Pass-Phrase!")
.await
.expect("login by username");
assert_eq!(by_username.id, created.id);
let by_email = authenticate::<AuthUser>("loginboth@example.com", "R1ght-Pass-Phrase!")
.await
.expect("login by email must resolve the same row");
assert_eq!(
by_email.id, created.id,
"email identifier finds the same user"
);
let by_email_cased = authenticate::<AuthUser>("LoginBoth@Example.COM", "R1ght-Pass-Phrase!")
.await
.expect("login by email is case-insensitive");
assert_eq!(by_email_cased.id, created.id);
let miss = authenticate::<AuthUser>("nobody@example.com", "R1ght-Pass-Phrase!").await;
assert!(matches!(miss, Err(AuthError::InvalidCredentials)));
}
#[tokio::test]
async fn authenticate_by_email_still_respects_inactive_flag() {
boot().await;
let pool = pool();
create_user(
"inactivemail",
"inactivemail@example.com",
"R1ght-Pass-Phrase!",
)
.await
.expect("create");
sqlx::query("UPDATE auth_user SET is_active = 0 WHERE username = 'inactivemail'")
.execute(&pool)
.await
.expect("deactivate");
for ident in ["inactivemail", "inactivemail@example.com"] {
let r = authenticate::<AuthUser>(ident, "R1ght-Pass-Phrase!").await;
assert!(
matches!(r, Err(AuthError::InvalidCredentials)),
"inactive user must not authenticate via `{ident}`"
);
}
}
#[tokio::test]
async fn random_password_hash_is_valid_unique_and_unguessable() {
let a = random_password_hash().await.expect("hash a");
let b = random_password_hash().await.expect("hash b");
assert!(!a.is_empty(), "the hash must not be empty");
assert!(
a.starts_with("$argon2"),
"must be a real argon2 PHC hash: {a}"
);
assert_ne!(a, b, "each call salts + randomizes independently");
assert!(
!verify_password("anything", &a).expect("verify must not error on a valid hash"),
"no plaintext should verify against a random hash"
);
}