use crate::AuthBackend;
use async_trait::async_trait;
use rusmes_proto::Username;
use sqlx::{AnyPool, Row};
use std::net::IpAddr;
#[derive(Debug, Clone)]
pub struct AuditLog {
pub username: String,
pub ip_address: Option<String>,
pub success: bool,
pub failure_reason: Option<String>,
pub timestamp: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashType {
Bcrypt,
Argon2,
ScramSha256,
}
impl HashType {
fn from_prefix(hash: &str) -> Self {
if hash.starts_with("$2") {
HashType::Bcrypt
} else if hash.starts_with("$argon2") {
HashType::Argon2
} else if hash.starts_with("$scram-sha-256$") {
HashType::ScramSha256
} else {
HashType::Bcrypt }
}
}
#[derive(Debug, Clone)]
pub struct UserMetadata {
pub enabled: bool,
pub quota_bytes: i64,
pub roles: Option<String>,
}
impl UserMetadata {
pub fn roles_vec(&self) -> Vec<String> {
self.roles
.as_ref()
.map(|r| r.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct SqlConfig {
pub database_url: String,
pub password_query: String,
pub list_users_query: String,
pub create_user_query: String,
pub delete_user_query: String,
pub update_password_query: String,
pub scram_params_query: Option<String>,
pub scram_stored_key_query: Option<String>,
pub scram_server_key_query: Option<String>,
pub store_scram_query: Option<String>,
pub audit_table: Option<String>,
pub max_connections: u32,
}
impl Default for SqlConfig {
fn default() -> Self {
Self {
database_url: "sqlite:file::memory:?cache=shared".to_string(),
password_query: "SELECT password_hash, enabled, quota_bytes, roles FROM users WHERE username = ?".to_string(),
list_users_query: "SELECT username FROM users".to_string(),
create_user_query: "INSERT INTO users (username, password_hash, enabled, quota_bytes, roles) VALUES (?, ?, 1, 1073741824, ?)".to_string(),
delete_user_query: "DELETE FROM users WHERE username = ?".to_string(),
update_password_query: "UPDATE users SET password_hash = ? WHERE username = ?".to_string(),
scram_params_query: Some("SELECT scram_salt, scram_iterations FROM users WHERE username = ?".to_string()),
scram_stored_key_query: Some("SELECT scram_stored_key FROM users WHERE username = ?".to_string()),
scram_server_key_query: Some("SELECT scram_server_key FROM users WHERE username = ?".to_string()),
store_scram_query: Some("UPDATE users SET scram_salt = ?, scram_iterations = ?, scram_stored_key = ?, scram_server_key = ? WHERE username = ?".to_string()),
audit_table: Some("auth_audit".to_string()),
max_connections: 10,
}
}
}
pub struct SqlBackend {
pool: AnyPool,
config: SqlConfig,
}
impl SqlBackend {
pub async fn new(config: SqlConfig) -> anyhow::Result<Self> {
sqlx::any::install_default_drivers();
let pool = sqlx::any::AnyPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.database_url)
.await?;
Ok(Self { pool, config })
}
pub async fn init_schema(&self) -> anyhow::Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
quota_bytes BIGINT NOT NULL DEFAULT 1073741824,
roles TEXT,
scram_salt BLOB,
scram_iterations INTEGER,
scram_stored_key BLOB,
scram_server_key BLOB,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
.execute(&self.pool)
.await?;
if self.config.audit_table.is_some() {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS auth_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip_address TEXT,
success INTEGER NOT NULL,
failure_reason TEXT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON auth_audit(timestamp)")
.execute(&self.pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_audit_username ON auth_audit(username)")
.execute(&self.pool)
.await?;
}
Ok(())
}
#[allow(dead_code)]
async fn log_audit(
&self,
username: &str,
ip: Option<IpAddr>,
success: bool,
failure_reason: Option<&str>,
) -> anyhow::Result<()> {
if self.config.audit_table.is_none() {
return Ok(());
}
let ip_str = ip.map(|i| i.to_string());
sqlx::query(
r#"
INSERT INTO auth_audit (username, ip_address, success, failure_reason)
VALUES (?, ?, ?, ?)
"#,
)
.bind(username)
.bind(ip_str)
.bind(if success { 1 } else { 0 })
.bind(failure_reason)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn get_user_metadata(
&self,
username: &Username,
) -> anyhow::Result<Option<UserMetadata>> {
let row = sqlx::query(&self.config.password_query)
.bind(username.to_string())
.fetch_optional(&self.pool)
.await?;
let row = match row {
Some(r) => r,
None => return Ok(None),
};
let enabled: i64 = row.try_get("enabled")?;
let quota_bytes: i64 = row.try_get("quota_bytes")?;
let roles: Option<String> = row.try_get("roles").ok();
Ok(Some(UserMetadata {
enabled: enabled != 0,
quota_bytes,
roles,
}))
}
pub async fn get_audit_logs(
&self,
username: &str,
limit: i64,
) -> anyhow::Result<Vec<AuditLog>> {
if self.config.audit_table.is_none() {
return Ok(Vec::new());
}
let rows = sqlx::query(
r#"
SELECT username, ip_address, success, failure_reason, timestamp
FROM auth_audit
WHERE username = ?
ORDER BY timestamp DESC
LIMIT ?
"#,
)
.bind(username)
.bind(limit)
.fetch_all(&self.pool)
.await?;
let mut logs = Vec::new();
for row in rows {
let log = AuditLog {
username: row.try_get("username")?,
ip_address: row.try_get("ip_address").ok(),
success: row.try_get::<i64, _>("success")? != 0,
failure_reason: row.try_get("failure_reason").ok(),
timestamp: row.try_get("timestamp")?,
};
logs.push(log);
}
Ok(logs)
}
fn verify_hash(&self, password: &str, hash: &str) -> anyhow::Result<bool> {
let hash_type = HashType::from_prefix(hash);
match hash_type {
HashType::Bcrypt => Ok(bcrypt::verify(password, hash)?),
HashType::Argon2 => {
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("Failed to parse Argon2 hash: {}", e))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
HashType::ScramSha256 => {
Err(anyhow::anyhow!(
"SCRAM-SHA-256 requires challenge/response authentication"
))
}
}
}
fn hash_password(&self, password: &str) -> anyhow::Result<String> {
Ok(bcrypt::hash(password, bcrypt::DEFAULT_COST)?)
}
}
#[async_trait]
impl AuthBackend for SqlBackend {
async fn authenticate(&self, username: &Username, password: &str) -> anyhow::Result<bool> {
let row = sqlx::query(&self.config.password_query)
.bind(username.to_string())
.fetch_optional(&self.pool)
.await?;
let row = match row {
Some(r) => r,
None => {
let _ = self
.log_audit(&username.to_string(), None, false, Some("User not found"))
.await;
return Ok(false);
}
};
let password_hash: String = row.try_get("password_hash")?;
let enabled: i64 = row.try_get("enabled")?;
if enabled == 0 {
let _ = self
.log_audit(&username.to_string(), None, false, Some("User disabled"))
.await;
return Ok(false);
}
let verified = self.verify_hash(password, &password_hash)?;
if verified {
let _ = self
.log_audit(&username.to_string(), None, true, None)
.await;
} else {
let _ = self
.log_audit(&username.to_string(), None, false, Some("Invalid password"))
.await;
}
Ok(verified)
}
async fn verify_identity(&self, username: &Username) -> anyhow::Result<bool> {
let row = sqlx::query(&self.config.password_query)
.bind(username.to_string())
.fetch_optional(&self.pool)
.await?;
Ok(row.is_some())
}
async fn list_users(&self) -> anyhow::Result<Vec<Username>> {
let rows = sqlx::query(&self.config.list_users_query)
.fetch_all(&self.pool)
.await?;
let users = rows
.into_iter()
.filter_map(|row| {
row.try_get::<String, _>("username")
.ok()
.and_then(|u| Username::new(u).ok())
})
.collect();
Ok(users)
}
async fn create_user(&self, username: &Username, password: &str) -> anyhow::Result<()> {
let password_hash = self.hash_password(password)?;
sqlx::query(&self.config.create_user_query)
.bind(username.to_string())
.bind(password_hash)
.bind("user") .execute(&self.pool)
.await?;
Ok(())
}
async fn delete_user(&self, username: &Username) -> anyhow::Result<()> {
sqlx::query(&self.config.delete_user_query)
.bind(username.to_string())
.execute(&self.pool)
.await?;
Ok(())
}
async fn change_password(&self, username: &Username, new_password: &str) -> anyhow::Result<()> {
let password_hash = self.hash_password(new_password)?;
sqlx::query(&self.config.update_password_query)
.bind(password_hash)
.bind(username.to_string())
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_scram_params(&self, username: &str) -> anyhow::Result<(Vec<u8>, u32)> {
let query = self
.config
.scram_params_query
.as_ref()
.ok_or_else(|| anyhow::anyhow!("SCRAM parameters query not configured"))?;
let row = sqlx::query(query)
.bind(username)
.fetch_one(&self.pool)
.await?;
let salt: Vec<u8> = row.try_get("scram_salt")?;
let iterations: i64 = row.try_get("scram_iterations")?;
Ok((salt, iterations as u32))
}
async fn get_scram_stored_key(&self, username: &str) -> anyhow::Result<Vec<u8>> {
let query = self
.config
.scram_stored_key_query
.as_ref()
.ok_or_else(|| anyhow::anyhow!("SCRAM StoredKey query not configured"))?;
let row = sqlx::query(query)
.bind(username)
.fetch_one(&self.pool)
.await?;
Ok(row.try_get("scram_stored_key")?)
}
async fn get_scram_server_key(&self, username: &str) -> anyhow::Result<Vec<u8>> {
let query = self
.config
.scram_server_key_query
.as_ref()
.ok_or_else(|| anyhow::anyhow!("SCRAM ServerKey query not configured"))?;
let row = sqlx::query(query)
.bind(username)
.fetch_one(&self.pool)
.await?;
Ok(row.try_get("scram_server_key")?)
}
async fn store_scram_credentials(
&self,
username: &Username,
salt: Vec<u8>,
iterations: u32,
stored_key: Vec<u8>,
server_key: Vec<u8>,
) -> anyhow::Result<()> {
let query = self
.config
.store_scram_query
.as_ref()
.ok_or_else(|| anyhow::anyhow!("SCRAM storage query not configured"))?;
sqlx::query(query)
.bind(&salt)
.bind(iterations as i64)
.bind(&stored_key)
.bind(&server_key)
.bind(username.to_string())
.execute(&self.pool)
.await?;
Ok(())
}
}
impl Clone for SqlBackend {
fn clone(&self) -> Self {
Self {
pool: self.pool.clone(),
config: self.config.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn unique_test_db_url() -> String {
let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
format!(
"sqlite:file:rusmes_auth_test_{}_{}?mode=memory&cache=shared",
pid, counter
)
}
async fn create_test_backend() -> SqlBackend {
let config = SqlConfig {
database_url: unique_test_db_url(),
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
backend
}
#[test]
fn test_hash_type_bcrypt() {
let hash = "$2b$12$KIXp8T/y7hOzQEu7qW3Ziu";
assert_eq!(HashType::from_prefix(hash), HashType::Bcrypt);
}
#[test]
fn test_hash_type_argon2() {
let hash = "$argon2id$v=19$m=65536,t=3,p=4$";
assert_eq!(HashType::from_prefix(hash), HashType::Argon2);
}
#[test]
fn test_hash_type_scram() {
let hash = "$scram-sha-256$iterations=4096";
assert_eq!(HashType::from_prefix(hash), HashType::ScramSha256);
}
#[test]
fn test_hash_type_default() {
let hash = "unknown_format";
assert_eq!(HashType::from_prefix(hash), HashType::Bcrypt);
}
#[test]
fn test_sql_config_default() {
let config = SqlConfig::default();
assert!(config.database_url.starts_with("sqlite:"));
assert_eq!(config.max_connections, 10);
assert!(config.scram_params_query.is_some());
}
#[test]
fn test_sql_config_custom() {
let config = SqlConfig {
database_url: "postgresql://localhost/rusmes".to_string(),
password_query: "SELECT hash FROM auth WHERE user = $1".to_string(),
max_connections: 20,
..Default::default()
};
assert_eq!(config.database_url, "postgresql://localhost/rusmes");
assert_eq!(config.max_connections, 20);
}
#[tokio::test]
async fn test_sql_backend_creation() {
let _backend = create_test_backend().await;
}
#[tokio::test]
async fn test_init_schema() {
let _backend = create_test_backend().await;
}
#[tokio::test]
async fn test_create_and_verify_user() {
let backend = create_test_backend().await;
let username = Username::new("testuser".to_string()).unwrap();
let password = "testpass123";
backend.create_user(&username, password).await.unwrap();
let verified = backend.verify_identity(&username).await.unwrap();
assert!(verified);
}
#[tokio::test]
async fn test_authenticate_user() {
let backend = create_test_backend().await;
let username = Username::new("authuser".to_string()).unwrap();
let password = "secure_password";
backend.create_user(&username, password).await.unwrap();
let authenticated = backend.authenticate(&username, password).await.unwrap();
assert!(authenticated);
let wrong_auth = backend
.authenticate(&username, "wrong_password")
.await
.unwrap();
assert!(!wrong_auth);
}
#[tokio::test]
async fn test_list_users() {
let backend = create_test_backend().await;
backend
.create_user(&Username::new("user1".to_string()).unwrap(), "pass1")
.await
.unwrap();
backend
.create_user(&Username::new("user2".to_string()).unwrap(), "pass2")
.await
.unwrap();
let users = backend.list_users().await.unwrap();
assert_eq!(users.len(), 2);
}
#[tokio::test]
async fn test_delete_user() {
let backend = create_test_backend().await;
let username = Username::new("deleteuser".to_string()).unwrap();
backend.create_user(&username, "password").await.unwrap();
let exists_before = backend.verify_identity(&username).await.unwrap();
assert!(exists_before);
backend.delete_user(&username).await.unwrap();
let exists_after = backend.verify_identity(&username).await.unwrap();
assert!(!exists_after);
}
#[tokio::test]
async fn test_change_password() {
let backend = create_test_backend().await;
let username = Username::new("changepassuser".to_string()).unwrap();
let old_password = "oldpass";
let new_password = "newpass";
backend.create_user(&username, old_password).await.unwrap();
let auth_old = backend.authenticate(&username, old_password).await.unwrap();
assert!(auth_old);
backend
.change_password(&username, new_password)
.await
.unwrap();
let auth_new = backend.authenticate(&username, new_password).await.unwrap();
assert!(auth_new);
let auth_old_after = backend.authenticate(&username, old_password).await.unwrap();
assert!(!auth_old_after);
}
#[tokio::test]
async fn test_nonexistent_user() {
let backend = create_test_backend().await;
let username = Username::new("nonexistent".to_string()).unwrap();
let authenticated = backend
.authenticate(&username, "anypassword")
.await
.unwrap();
assert!(!authenticated);
}
#[test]
fn test_bcrypt_hash_verification() {
let password = "test_password";
let hash = bcrypt::hash(password, bcrypt::DEFAULT_COST).unwrap();
let verified = bcrypt::verify(password, &hash).unwrap();
assert!(verified);
}
#[test]
fn test_password_query_format() {
let config = SqlConfig::default();
assert!(config.password_query.contains("SELECT"));
assert!(config.password_query.contains("password_hash"));
assert!(config.password_query.contains("enabled"));
}
#[test]
fn test_scram_queries_configured() {
let config = SqlConfig::default();
assert!(config.scram_params_query.is_some());
assert!(config.scram_stored_key_query.is_some());
assert!(config.scram_server_key_query.is_some());
assert!(config.store_scram_query.is_some());
}
#[tokio::test]
async fn test_multiple_users() {
let backend = create_test_backend().await;
for i in 0..5 {
let username = Username::new(format!("user{}", i)).unwrap();
backend
.create_user(&username, &format!("pass{}", i))
.await
.unwrap();
}
let users = backend.list_users().await.unwrap();
assert_eq!(users.len(), 5);
}
#[tokio::test]
async fn test_duplicate_username() {
let backend = create_test_backend().await;
let username = Username::new("duplicate".to_string()).unwrap();
backend.create_user(&username, "pass1").await.unwrap();
let result = backend.create_user(&username, "pass2").await;
assert!(result.is_err());
}
#[test]
fn test_hash_type_variants() {
assert_eq!(HashType::Bcrypt, HashType::Bcrypt);
assert_ne!(HashType::Bcrypt, HashType::Argon2);
assert_ne!(HashType::Argon2, HashType::ScramSha256);
}
#[tokio::test]
async fn test_empty_user_list() {
let backend = create_test_backend().await;
let users = backend.list_users().await.unwrap();
assert_eq!(users.len(), 0);
}
#[tokio::test]
async fn test_user_metadata() {
let backend = create_test_backend().await;
let username = Username::new("metauser".to_string()).unwrap();
backend.create_user(&username, "password").await.unwrap();
let metadata = backend.get_user_metadata(&username).await.unwrap();
assert!(metadata.is_some());
let meta = metadata.unwrap();
assert!(meta.enabled);
assert_eq!(meta.quota_bytes, 1073741824);
assert_eq!(meta.roles, Some("user".to_string()));
}
#[tokio::test]
async fn test_user_metadata_roles_vec() {
let metadata = UserMetadata {
enabled: true,
quota_bytes: 1000,
roles: Some("user,admin,moderator".to_string()),
};
let roles = metadata.roles_vec();
assert_eq!(roles.len(), 3);
assert!(roles.contains(&"user".to_string()));
assert!(roles.contains(&"admin".to_string()));
assert!(roles.contains(&"moderator".to_string()));
}
#[tokio::test]
async fn test_user_metadata_no_roles() {
let metadata = UserMetadata {
enabled: true,
quota_bytes: 1000,
roles: None,
};
let roles = metadata.roles_vec();
assert_eq!(roles.len(), 0);
}
#[tokio::test]
async fn test_audit_logging_success() {
let backend = create_test_backend().await;
let username = Username::new("audituser".to_string()).unwrap();
backend.create_user(&username, "password").await.unwrap();
backend.authenticate(&username, "password").await.unwrap();
let logs = backend.get_audit_logs("audituser", 10).await.unwrap();
assert!(!logs.is_empty());
}
#[tokio::test]
async fn test_audit_logging_failure() {
let backend = create_test_backend().await;
backend
.log_audit("nonexistent", None, false, Some("User not found"))
.await
.unwrap();
let logs = backend.get_audit_logs("nonexistent", 10).await.unwrap();
assert!(!logs.is_empty());
assert!(!logs[0].success);
}
#[tokio::test]
async fn test_audit_with_ip_address() {
let backend = create_test_backend().await;
let ip: IpAddr = "127.0.0.1".parse().unwrap();
backend
.log_audit("testuser", Some(ip), true, None)
.await
.unwrap();
let logs = backend.get_audit_logs("testuser", 10).await.unwrap();
assert!(!logs.is_empty());
assert_eq!(logs[0].ip_address, Some("127.0.0.1".to_string()));
}
#[tokio::test]
async fn test_audit_multiple_entries() {
let backend = create_test_backend().await;
for i in 0..5 {
backend
.log_audit(&format!("user{}", i), None, i % 2 == 0, None)
.await
.unwrap();
}
let logs = backend.get_audit_logs("user0", 10).await.unwrap();
assert!(!logs.is_empty());
}
#[tokio::test]
async fn test_audit_limit() {
let backend = create_test_backend().await;
for _ in 0..10 {
backend
.log_audit("limituser", None, true, None)
.await
.unwrap();
}
let logs = backend.get_audit_logs("limituser", 5).await.unwrap();
assert_eq!(logs.len(), 5);
}
#[tokio::test]
#[ignore = "stress: SQLite connection pool under concurrent bcrypt load; run manually with --ignored"]
async fn test_connection_pool() {
let config = SqlConfig {
database_url: unique_test_db_url(),
max_connections: 20,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
let mut handles = vec![];
for i in 0..10 {
let username = Username::new(format!("pooluser{}", i)).expect("Username::new failed");
let password = format!("pass{}", i);
let b = backend.clone();
let handle = tokio::spawn(async move { b.create_user(&username, &password).await });
handles.push(handle);
}
for handle in handles {
let result = handle.await.expect("task panicked");
assert!(result.is_ok(), "create_user failed: {:?}", result.err());
}
}
#[tokio::test]
async fn test_argon2_hash_verification() {
use argon2::password_hash::{rand_core::OsRng, PasswordHash, SaltString};
use argon2::{Argon2, PasswordHasher, PasswordVerifier};
let password = "test_password";
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hash = argon2
.hash_password(password.as_bytes(), &salt)
.unwrap()
.to_string();
let parsed = PasswordHash::new(&hash).unwrap();
let verify_result = Argon2::default().verify_password(password.as_bytes(), &parsed);
assert!(verify_result.is_ok());
}
#[tokio::test]
async fn test_scram_hash_error() {
let config = SqlConfig {
database_url: unique_test_db_url(),
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
let result = backend.verify_hash("password", "$scram-sha-256$test");
assert!(result.is_err());
}
#[tokio::test]
async fn test_verify_nonexistent_user() {
let backend = create_test_backend().await;
let username = Username::new("ghost".to_string()).unwrap();
let verified = backend.verify_identity(&username).await.unwrap();
assert!(!verified);
}
#[tokio::test]
async fn test_password_hash_different() {
let config = SqlConfig {
database_url: unique_test_db_url(),
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
let hash1 = backend
.hash_password("password")
.expect("hash_password failed");
let hash2 = backend
.hash_password("password")
.expect("hash_password failed");
assert_ne!(hash1, hash2);
assert!(backend.verify_hash("password", &hash1).unwrap());
assert!(backend.verify_hash("password", &hash2).unwrap());
}
#[tokio::test]
async fn test_special_characters_in_username() {
let backend = create_test_backend().await;
let username = Username::new("test.user+tag@example.com".to_string()).unwrap();
backend.create_user(&username, "password").await.unwrap();
let authenticated = backend.authenticate(&username, "password").await.unwrap();
assert!(authenticated);
}
#[tokio::test]
async fn test_long_password() {
let backend = create_test_backend().await;
let username = Username::new("longpassuser".to_string()).unwrap();
let password = "a".repeat(100);
backend.create_user(&username, &password).await.unwrap();
let authenticated = backend.authenticate(&username, &password).await.unwrap();
assert!(authenticated);
}
#[tokio::test]
async fn test_empty_password_rejection() {
let backend = create_test_backend().await;
let username = Username::new("emptypass".to_string()).unwrap();
backend.create_user(&username, "").await.unwrap();
let authenticated = backend.authenticate(&username, "").await.unwrap();
assert!(authenticated);
let auth_wrong = backend.authenticate(&username, "notblank").await.unwrap();
assert!(!auth_wrong);
}
#[tokio::test]
async fn test_case_sensitive_password() {
let backend = create_test_backend().await;
let username = Username::new("caseuser".to_string()).unwrap();
backend.create_user(&username, "Password123").await.unwrap();
let auth_correct = backend
.authenticate(&username, "Password123")
.await
.unwrap();
assert!(auth_correct);
let auth_wrong = backend
.authenticate(&username, "password123")
.await
.unwrap();
assert!(!auth_wrong);
}
#[tokio::test]
#[ignore = "stress: SQLite pool timeout under concurrent bcrypt authentication; run manually with --ignored"]
async fn test_concurrent_authentication() {
let backend = create_test_backend().await;
let username = Username::new("concurrent".to_string()).expect("Username::new failed");
backend
.create_user(&username, "password")
.await
.expect("create_user failed");
let mut handles = vec![];
for _ in 0..10 {
let b = backend.clone();
let u = username.clone();
let handle = tokio::spawn(async move { b.authenticate(&u, "password").await });
handles.push(handle);
}
for handle in handles {
let result = handle.await.expect("task panicked");
assert!(result.expect("authenticate failed"));
}
}
#[tokio::test]
async fn test_database_connection_reuse() {
let backend = create_test_backend().await;
for i in 0..20 {
let username = Username::new(format!("reuse{}", i)).unwrap();
backend.create_user(&username, "password").await.unwrap();
}
let users = backend.list_users().await.unwrap();
assert_eq!(users.len(), 20);
}
#[test]
fn test_hash_type_copy_trait() {
let hash_type = HashType::Bcrypt;
let copied = hash_type;
assert_eq!(hash_type, copied);
}
#[test]
fn test_user_metadata_clone() {
let metadata = UserMetadata {
enabled: true,
quota_bytes: 1000,
roles: Some("admin".to_string()),
};
let cloned = metadata.clone();
assert_eq!(cloned.enabled, metadata.enabled);
assert_eq!(cloned.quota_bytes, metadata.quota_bytes);
}
#[test]
fn test_audit_log_debug() {
let log = AuditLog {
username: "test".to_string(),
ip_address: Some("127.0.0.1".to_string()),
success: true,
failure_reason: None,
timestamp: "2025-01-01 00:00:00".to_string(),
};
let debug_str = format!("{:?}", log);
assert!(debug_str.contains("test"));
}
#[tokio::test]
async fn test_scram_params_not_configured() {
let config = SqlConfig {
database_url: unique_test_db_url(),
scram_params_query: None,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
let result = backend.get_scram_params("testuser").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_scram_stored_key_not_configured() {
let config = SqlConfig {
database_url: unique_test_db_url(),
scram_stored_key_query: None,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
let result = backend.get_scram_stored_key("testuser").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_scram_server_key_not_configured() {
let config = SqlConfig {
database_url: unique_test_db_url(),
scram_server_key_query: None,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
let result = backend.get_scram_server_key("testuser").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_store_scram_not_configured() {
let config = SqlConfig {
database_url: unique_test_db_url(),
store_scram_query: None,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
let username = Username::new("scram".to_string()).expect("Username::new failed");
let result = backend
.store_scram_credentials(&username, vec![1, 2, 3], 4096, vec![4, 5, 6], vec![7, 8, 9])
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_audit_disabled() {
let config = SqlConfig {
database_url: unique_test_db_url(),
audit_table: None,
..Default::default()
};
let backend = SqlBackend::new(config)
.await
.expect("SqlBackend::new failed");
backend.init_schema().await.expect("init_schema failed");
backend.log_audit("user", None, true, None).await.unwrap();
let logs = backend.get_audit_logs("user", 10).await.unwrap();
assert_eq!(logs.len(), 0);
}
#[tokio::test]
async fn test_user_metadata_nonexistent() {
let backend = create_test_backend().await;
let username = Username::new("phantom".to_string()).unwrap();
let metadata = backend.get_user_metadata(&username).await.unwrap();
assert!(metadata.is_none());
}
#[tokio::test]
async fn test_custom_database_url() {
let config = SqlConfig {
database_url: unique_test_db_url(),
..Default::default()
};
let backend = SqlBackend::new(config).await;
assert!(backend.is_ok());
}
}