use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tokio::sync::OnceCell;
use umbral::prelude::Plugin;
use umbral_auth::{
AuthError, AuthPlugin, AuthUser, UserModel, authenticate, hash_password, set_password,
verify_password,
};
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, umbral::orm::Model)]
pub struct CustomUser {
pub id: i64,
pub username: String,
pub password_hash: String,
pub display_name: String,
pub tenant_id: i64,
pub is_active: bool,
}
impl UserModel for CustomUser {
fn id(&self) -> i64 {
self.id
}
fn username(&self) -> &str {
&self.username
}
fn password_hash(&self) -> &str {
&self.password_hash
}
fn set_password_hash(&mut self, hash: String) {
self.password_hash = hash;
}
fn is_active(&self) -> bool {
self.is_active
}
}
static BOOT_CUSTOM: OnceCell<()> = OnceCell::const_new();
async fn boot_custom() {
BOOT_CUSTOM
.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 custom_user test DB");
let db_path = tmp.path().join("umbral_custom_user.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");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.plugin(AuthPlugin::<CustomUser>::default())
.build()
.expect("App::build should succeed with AuthPlugin::<CustomUser>");
let pool = umbral::db::pool();
sqlx::query(
"CREATE TABLE custom_user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
tenant_id INTEGER NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
)",
)
.execute(&pool)
.await
.expect("create custom_user table");
})
.await;
}
static BOOT_DEFAULT: OnceCell<()> = OnceCell::const_new();
async fn boot_default() {
BOOT_DEFAULT
.get_or_init(|| async {
let _ = AuthPlugin::<AuthUser>::default();
})
.await;
}
#[tokio::test]
async fn custom_plugin_registers_custom_user_model() {
boot_custom().await;
let models = umbral::migrate::models_for_plugin("auth");
assert_eq!(
models.len(),
1,
"AuthPlugin::<CustomUser> should contribute exactly one model; got {models:?}",
);
assert_eq!(models[0].name, "CustomUser");
assert_eq!(models[0].table, "custom_user");
}
#[tokio::test]
async fn authenticate_generic_works_for_custom_user() {
boot_custom().await;
let pool = umbral::db::pool();
let hash = hash_password("s3cret").expect("hash_password should succeed");
sqlx::query(
"INSERT INTO custom_user (username, password_hash, display_name, tenant_id, is_active)
VALUES (?, ?, ?, ?, 1)",
)
.bind("alice_tenant")
.bind(&hash)
.bind("Alice (Tenant 42)")
.bind(42_i64)
.execute(&pool)
.await
.expect("insert custom_user row");
let found = authenticate::<CustomUser>("alice_tenant", "s3cret")
.await
.expect("authenticate::<CustomUser> should succeed for valid credentials");
assert_eq!(found.username, "alice_tenant");
assert_eq!(found.display_name, "Alice (Tenant 42)");
assert_eq!(found.tenant_id, 42);
assert!(found.is_active());
assert!(!found.is_staff(), "is_staff default is false");
assert!(!found.is_superuser(), "is_superuser default is false");
}
#[tokio::test]
async fn authenticate_rejects_wrong_password_for_custom_user() {
boot_custom().await;
let pool = umbral::db::pool();
let hash = hash_password("correctpass").expect("hash");
sqlx::query(
"INSERT INTO custom_user (username, password_hash, display_name, tenant_id, is_active)
VALUES (?, ?, ?, ?, 1)",
)
.bind("bob_tenant")
.bind(&hash)
.bind("Bob")
.bind(1_i64)
.execute(&pool)
.await
.expect("insert");
let result = authenticate::<CustomUser>("bob_tenant", "wrongpass").await;
assert!(
matches!(result, Err(AuthError::InvalidCredentials)),
"wrong password must return InvalidCredentials; got {result:?}",
);
}
#[tokio::test]
async fn authenticate_rejects_inactive_custom_user() {
boot_custom().await;
let pool = umbral::db::pool();
let hash = hash_password("pass").expect("hash");
sqlx::query(
"INSERT INTO custom_user (username, password_hash, display_name, tenant_id, is_active)
VALUES (?, ?, ?, ?, 0)",
)
.bind("carol_inactive")
.bind(&hash)
.bind("Carol")
.bind(1_i64)
.execute(&pool)
.await
.expect("insert");
let result = authenticate::<CustomUser>("carol_inactive", "pass").await;
assert!(
matches!(result, Err(AuthError::InvalidCredentials)),
"inactive custom user must not authenticate; got {result:?}",
);
}
#[tokio::test]
async fn set_password_works_for_custom_user() {
boot_custom().await;
let pool = umbral::db::pool();
let initial_hash = hash_password("oldpassword").expect("hash");
sqlx::query(
"INSERT INTO custom_user (username, password_hash, display_name, tenant_id, is_active)
VALUES (?, ?, ?, ?, 1)",
)
.bind("dave_tenant")
.bind(&initial_hash)
.bind("Dave")
.bind(7_i64)
.execute(&pool)
.await
.expect("insert");
let mut user = authenticate::<CustomUser>("dave_tenant", "oldpassword")
.await
.expect("first authenticate should succeed");
let old_hash = user.password_hash.clone();
set_password(&mut user, "newpassword")
.await
.expect("set_password should succeed");
assert_ne!(
user.password_hash, old_hash,
"set_password must update the in-place hash",
);
authenticate::<CustomUser>("dave_tenant", "newpassword")
.await
.expect("new password must work after set_password");
let stale = authenticate::<CustomUser>("dave_tenant", "oldpassword").await;
assert!(
matches!(stale, Err(AuthError::InvalidCredentials)),
"old password must stop working after set_password; got {stale:?}",
);
}
#[tokio::test]
async fn default_auth_plugin_type_resolves_to_auth_user() {
boot_default().await;
let plugin: AuthPlugin = AuthPlugin::default();
assert_eq!(plugin.name(), "auth");
assert!(plugin.user_model_name.is_none());
let plugin2: AuthPlugin<AuthUser> = AuthPlugin::<AuthUser>::default();
assert_eq!(plugin2.name(), "auth");
}
#[test]
fn user_model_name_builder_sets_the_field() {
let plugin = AuthPlugin::<CustomUser>::default().user_model_name("tenant_user");
assert_eq!(
plugin.user_model_name.as_deref(),
Some("tenant_user"),
"user_model_name builder should set the field",
);
}
#[test]
fn user_model_default_flags() {
let user = CustomUser {
id: 1,
username: "test".into(),
password_hash: "hash".into(),
display_name: "Test".into(),
tenant_id: 0,
is_active: true,
};
assert!(user.is_active(), "is_active reflects the struct field");
assert!(!user.is_staff(), "is_staff defaults to false");
assert!(!user.is_superuser(), "is_superuser defaults to false");
}
#[test]
fn hash_and_verify_work_for_custom_user_password_column() {
let hash = hash_password("my-secret").expect("hash_password should not fail");
assert!(
verify_password("my-secret", &hash).expect("verify should not error"),
"correct plaintext must verify",
);
assert!(
!verify_password("wrong", &hash).expect("verify should not error"),
"wrong plaintext must not verify",
);
}