From 3486f0fdf800c81fe8f59de5640356d6129576c3 Mon Sep 17 00:00:00 2001 From: perf3ct Date: Sun, 2 Nov 2025 14:29:12 -0800 Subject: [PATCH] feat(server): allow for random admin password generation --- .env.example | 19 ++ src/commands/mod.rs | 3 + src/commands/reset_admin.rs | 72 ++++++ src/db/users.rs | 44 ++++ src/lib.rs | 1 + src/main.rs | 53 ++++- src/seed.rs | 63 +++-- src/utils/security.rs | 82 ++++++- tests/integration_admin_password_tests.rs | 277 ++++++++++++++++++++++ 9 files changed, 586 insertions(+), 28 deletions(-) create mode 100644 src/commands/mod.rs create mode 100644 src/commands/reset_admin.rs create mode 100644 tests/integration_admin_password_tests.rs diff --git a/.env.example b/.env.example index a810dfc..001c356 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,25 @@ SERVER_ADDRESS=0.0.0.0:8000 # When disabled, only OIDC authentication is available ALLOW_LOCAL_AUTH=true +# Admin User Configuration (optional) +# IMPORTANT: These variables are only used when creating the initial admin user. +# If not set, the system will auto-generate a secure password on first startup. +# +# ADMIN_USERNAME: Custom admin username (default: "admin") +# ADMIN_PASSWORD: Set admin password (min 8 chars). If not set, a secure 24-character +# password will be auto-generated and displayed once at startup. +# +# SECURITY BEST PRACTICES: +# - For production: Leave ADMIN_PASSWORD unset to auto-generate secure password +# - For development: You can set a simple password for convenience +# - For automation: Use secrets management to inject ADMIN_PASSWORD securely +# +# Examples: +# ADMIN_USERNAME=superadmin +# ADMIN_PASSWORD=MySecureP@ssw0rd123! +# +# To reset admin password later, run: readur reset-admin-password + # OIDC Configuration (optional - see docs/oidc-setup.md for details) # OIDC_ENABLED=true # OIDC_CLIENT_ID=your-client-id diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..8911de6 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub mod reset_admin; + +pub use reset_admin::reset_admin_password; diff --git a/src/commands/reset_admin.rs b/src/commands/reset_admin.rs new file mode 100644 index 0000000..54c36d2 --- /dev/null +++ b/src/commands/reset_admin.rs @@ -0,0 +1,72 @@ +use anyhow::{Context, Result}; +use std::env; + +use crate::db::Database; +use crate::utils::security::generate_secure_password; + +/// Reset the admin user's password +/// +/// This command resets the admin user's password to either: +/// 1. A value specified via the ADMIN_PASSWORD environment variable, or +/// 2. A newly generated secure random password (24 characters) +/// +/// The admin username defaults to "admin" but can be customized via ADMIN_USERNAME env var. +/// +/// # Arguments +/// * `db` - Database connection +/// +/// # Returns +/// Result indicating success or failure +pub async fn reset_admin_password(db: &Database) -> Result<()> { + // Get admin username from env var or use default + let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); + + // Check if admin user exists + let admin_user = db + .get_user_by_username(&admin_username) + .await + .context("Failed to query database for admin user")?; + + if admin_user.is_none() { + anyhow::bail!( + "Admin user '{}' not found. Please ensure the user exists before resetting password.", + admin_username + ); + } + + // Get new password from env var or generate one + let new_password = if let Ok(pwd) = env::var("ADMIN_PASSWORD") { + if pwd.len() < 8 { + anyhow::bail!("ADMIN_PASSWORD must be at least 8 characters long"); + } + pwd + } else { + generate_secure_password(24) + }; + + // Reset the password + db.reset_user_password(&admin_username, &new_password) + .await + .context("Failed to reset admin password")?; + + // Display success message with credentials + print_success_message(&admin_username, &new_password); + + Ok(()) +} + +fn print_success_message(username: &str, password: &str) { + println!(); + println!("=============================================="); + println!(" ADMIN PASSWORD RESET SUCCESSFUL"); + println!("=============================================="); + println!(); + println!("Username: {}", username); + println!("Password: {}", password); + println!(); + println!("āš ļø SAVE THESE CREDENTIALS IMMEDIATELY!"); + println!("āš ļø This password will not be shown again."); + println!(); + println!("=============================================="); + println!(); +} diff --git a/src/db/users.rs b/src/db/users.rs index aea5520..6f5f739 100644 --- a/src/db/users.rs +++ b/src/db/users.rs @@ -319,4 +319,48 @@ impl Database { auth_provider: row.get::("auth_provider").try_into().unwrap_or(AuthProvider::Oidc), }) } + + /// Reset a user's password by username + /// + /// This function is useful for password recovery and admin password resets. + /// The password is automatically hashed with bcrypt before storing. + /// + /// # Arguments + /// * `username` - The username of the user whose password should be reset + /// * `new_password` - The new plaintext password (will be hashed) + /// + /// # Returns + /// The updated User object, or an error if the user doesn't exist + pub async fn reset_user_password(&self, username: &str, new_password: &str) -> Result { + let password_hash = bcrypt::hash(new_password, 12)?; + + let row = sqlx::query( + r#" + UPDATE users + SET password_hash = $2, + updated_at = NOW() + WHERE username = $1 + RETURNING id, username, email, password_hash, role, created_at, updated_at, + oidc_subject, oidc_issuer, oidc_email, auth_provider + "# + ) + .bind(username) + .bind(&password_hash) + .fetch_one(&self.pool) + .await?; + + Ok(User { + id: row.get("id"), + username: row.get("username"), + email: row.get("email"), + password_hash: row.get("password_hash"), + role: row.get::("role").try_into().unwrap_or(crate::models::UserRole::User), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + oidc_subject: row.get("oidc_subject"), + oidc_issuer: row.get("oidc_issuer"), + oidc_email: row.get("oidc_email"), + auth_provider: row.get::("auth_provider").try_into().unwrap_or(AuthProvider::Local), + }) + } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 5f1854b..f476192 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod commands; pub mod config; pub mod db; pub mod db_guardrails_simple; diff --git a/src/main.rs b/src/main.rs index 8239122..85c7145 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,13 +7,33 @@ use std::sync::Arc; use tower_http::{cors::CorsLayer, services::{ServeDir, ServeFile}}; use tracing::{info, error, warn}; use anyhow; -use sqlx::{Row, Column}; +use sqlx::Column; +use clap::{Parser, Subcommand}; use readur::{config::Config, db::Database, AppState, *}; +mod commands; + #[cfg(test)] mod tests; +/// Readur - Document Management System +#[derive(Parser)] +#[command(name = "readur")] +#[command(about = "Readur document management system", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Start the Readur web server (default) + Serve, + /// Reset the admin user's password + ResetAdminPassword, +} + /// Determines the correct path for static files based on the environment /// Checks multiple possible locations in order of preference fn determine_static_files_path() -> std::path::PathBuf { @@ -53,6 +73,9 @@ fn determine_static_files_path() -> std::path::PathBuf { #[tokio::main] async fn main() -> anyhow::Result<()> { + // Parse CLI arguments + let cli = Cli::parse(); + // Initialize logging with custom filters to reduce spam from noisy crates // Users can override with RUST_LOG environment variable, e.g.: // RUST_LOG=debug cargo run (enable debug for all) @@ -63,13 +86,37 @@ async fn main() -> anyhow::Result<()> { // Default filter when RUST_LOG is not set tracing_subscriber::EnvFilter::new("info") .add_directive("pdf_extract=error".parse().unwrap()) // Suppress pdf_extract WARN spam - .add_directive("sqlx::postgres::notice=warn".parse().unwrap()) // Suppress PostgreSQL NOTICE spam + .add_directive("sqlx::postgres::notice=warn".parse().unwrap()) // Suppress PostgreSQL NOTICE spam .add_directive("readur=info".parse().unwrap()) // Keep our app logs at info }); - + tracing_subscriber::fmt() .with_env_filter(env_filter) .init(); + + // Handle CLI commands + match cli.command { + Some(Commands::ResetAdminPassword) => { + // For reset-admin-password command, we only need database connection + println!("\nšŸ”‘ RESET ADMIN PASSWORD"); + println!("{}", "=".repeat(60)); + + // Load minimal config (we only need database URL) + let config = Config::from_env()?; + + // Connect to database + let db = Database::new(&config.database_url).await?; + + // Run reset command + commands::reset_admin_password(&db).await?; + + return Ok(()); + } + Some(Commands::Serve) | None => { + // Default: Start the web server + // Continue with normal server startup below + } + } println!("\nšŸš€ READUR APPLICATION STARTUP"); println!("{}", "=".repeat(60)); diff --git a/src/seed.rs b/src/seed.rs index 943b1a9..e5f7bad 100644 --- a/src/seed.rs +++ b/src/seed.rs @@ -1,49 +1,72 @@ use anyhow::Result; use tracing::info; +use std::env; use crate::db::Database; use crate::models::CreateUser; +use crate::utils::security::generate_secure_password; pub async fn seed_admin_user(db: &Database) -> Result<()> { - let admin_username = "admin"; - let admin_email = "admin@readur.com"; - let admin_password = "readur2024"; + // Get admin username from env var or use default + let admin_username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); + + // Generate default email based on username + let admin_email = format!("{}@readur.com", admin_username); // Check if admin user already exists - match db.get_user_by_username(admin_username).await { + match db.get_user_by_username(&admin_username).await { Ok(Some(_)) => { - info!("āœ… ADMIN USER ALREADY EXISTS!"); - info!("šŸ“§ Email: {}", admin_email); - info!("šŸ‘¤ Username: {}", admin_username); - info!("šŸ”‘ Password: {}", admin_password); - info!("šŸš€ You can now login to the application at http://localhost:8000"); + info!("āœ… Admin user '{}' already exists", admin_username); + info!("šŸš€ You can login at http://localhost:8000"); + info!("šŸ’” To reset the admin password, run: readur reset-admin-password"); return Ok(()); } Ok(None) => { // User doesn't exist, create it } Err(e) => { - info!("Error checking for admin user: {}", e); + info!("āš ļø Error checking for admin user: {}", e); } } + // Get password from env var or generate a secure one + let admin_password = if let Ok(pwd) = env::var("ADMIN_PASSWORD") { + if pwd.len() < 8 { + anyhow::bail!("ADMIN_PASSWORD must be at least 8 characters long"); + } + pwd + } else { + generate_secure_password(24) + }; + let create_user = CreateUser { - username: admin_username.to_string(), - email: admin_email.to_string(), - password: admin_password.to_string(), + username: admin_username.clone(), + email: admin_email.clone(), + password: admin_password.clone(), role: Some(crate::models::UserRole::Admin), }; match db.create_user(create_user).await { Ok(user) => { - info!("āœ… ADMIN USER CREATED SUCCESSFULLY!"); - info!("šŸ“§ Email: {}", admin_email); - info!("šŸ‘¤ Username: {}", admin_username); - info!("šŸ”‘ Password: {}", admin_password); - info!("šŸ†” User ID: {}", user.id); - info!("šŸš€ You can now login to the application at http://localhost:8000"); + println!(); + println!("=============================================="); + println!(" READUR ADMIN USER CREATED"); + println!("=============================================="); + println!(); + println!("Username: {}", admin_username); + println!("Email: {}", admin_email); + println!("Password: {}", admin_password); + println!("User ID: {}", user.id); + println!(); + println!("āš ļø SAVE THESE CREDENTIALS IMMEDIATELY!"); + println!("āš ļø This password will not be shown again."); + println!(); + println!("=============================================="); + println!(); + info!("šŸš€ You can now login at http://localhost:8000"); + info!("šŸ’” To reset the admin password later, run: readur reset-admin-password"); } Err(e) => { - info!("Failed to create admin user: {}", e); + info!("āŒ Failed to create admin user: {}", e); } } diff --git a/src/utils/security.rs b/src/utils/security.rs index db8525b..c074a0c 100644 --- a/src/utils/security.rs +++ b/src/utils/security.rs @@ -3,6 +3,7 @@ use anyhow::Result; use std::path::{Path, PathBuf, Component}; use tracing::{warn, debug}; +use rand::Rng; /// Validate and sanitize file paths to prevent path traversal attacks pub fn validate_and_sanitize_path(input_path: &str) -> Result { @@ -225,6 +226,35 @@ pub fn validate_path_within_base(path: &str, base_dir: &str) -> Result<()> { Ok(()) } +/// Generate a cryptographically secure random password +/// +/// Generates a password with the specified length containing: +/// - Uppercase letters (A-Z) +/// - Lowercase letters (a-z) +/// - Numbers (0-9) +/// - Special characters (!@#$%^&*-_=+) +/// +/// # Arguments +/// * `length` - The desired password length (minimum 12, recommended 24+) +/// +/// # Returns +/// A randomly generated password string +pub fn generate_secure_password(length: usize) -> String { + const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789\ + !@#$%^&*-_=+"; + + let mut rng = rand::thread_rng(); + + (0..length) + .map(|_| { + let idx = rng.gen_range(0..CHARSET.len()); + CHARSET[idx] as char + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -329,24 +359,66 @@ mod tests { #[test] fn test_validate_path_within_base_traversal_attempts() { use std::fs; - + let test_base = "test_security_validation"; fs::create_dir_all(test_base).unwrap_or(()); - + // Test various path traversal attempts let traversal_attempts = vec![ "../../../etc/passwd", - "./test_security_validation/../../../etc/passwd", + "./test_security_validation/../../../etc/passwd", "test_security_validation/../outside.txt", "./test_security_validation/documents/../../outside.txt", ]; - + for attempt in traversal_attempts { let result = validate_path_within_base(attempt, "./test_security_validation"); assert!(result.is_err(), "Should reject path traversal attempt: {}", attempt); } - + // Clean up fs::remove_dir_all(test_base).unwrap_or(()); } + + #[test] + fn test_generate_secure_password_length() { + // Test default length + let password = generate_secure_password(24); + assert_eq!(password.len(), 24, "Password should be exactly 24 characters"); + + // Test different lengths + let password_12 = generate_secure_password(12); + assert_eq!(password_12.len(), 12, "Password should be exactly 12 characters"); + + let password_32 = generate_secure_password(32); + assert_eq!(password_32.len(), 32, "Password should be exactly 32 characters"); + } + + #[test] + fn test_generate_secure_password_character_composition() { + let password = generate_secure_password(100); // Use longer password for better test coverage + + // Check for uppercase letters + let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase()); + assert!(has_uppercase, "Password should contain at least one uppercase letter"); + + // Check for lowercase letters + let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase()); + assert!(has_lowercase, "Password should contain at least one lowercase letter"); + + // Check for digits + let has_digit = password.chars().any(|c| c.is_ascii_digit()); + assert!(has_digit, "Password should contain at least one digit"); + + // Check for special characters + let special_chars = "!@#$%^&*-_=+"; + let has_special = password.chars().any(|c| special_chars.contains(c)); + assert!(has_special, "Password should contain at least one special character"); + + // Ensure all characters are from the allowed charset + let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+"; + for ch in password.chars() { + assert!(charset.contains(ch), "Password contains invalid character: {}", ch); + } + } } \ No newline at end of file diff --git a/tests/integration_admin_password_tests.rs b/tests/integration_admin_password_tests.rs new file mode 100644 index 0000000..6d01ec1 --- /dev/null +++ b/tests/integration_admin_password_tests.rs @@ -0,0 +1,277 @@ +use anyhow::Result; +use readur::test_utils::{TestContext, TestAuthHelper}; +use readur::{seed, commands, models::{CreateUser, UserRole}}; + +/// Test that admin user is created with auto-generated password on first run +#[tokio::test] +async fn test_admin_seed_creates_user_with_auto_password() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Clear any env vars to test auto-generation + std::env::remove_var("ADMIN_PASSWORD"); + std::env::remove_var("ADMIN_USERNAME"); + + // Run seed + seed::seed_admin_user(&ctx.state.db).await?; + + // Verify admin user exists + let admin = ctx.state.db.get_user_by_username("admin").await? + .expect("Admin user should exist"); + + // Verify role is Admin + assert_eq!(admin.role, UserRole::Admin, "User should have Admin role"); + + // Verify password is hashed (bcrypt format) + assert!(admin.password_hash.is_some(), "Password hash should exist"); + let hash = admin.password_hash.unwrap(); + assert!( + hash.starts_with("$2b$") || hash.starts_with("$2a$"), + "Password should be bcrypt hashed" + ); + + // Verify email format + assert_eq!(admin.email, "admin@readur.com", "Email should use default format"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that admin user is created with provided ADMIN_PASSWORD +#[tokio::test] +async fn test_admin_seed_uses_env_password() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Set password via environment variable + std::env::set_var("ADMIN_PASSWORD", "testpass123"); + std::env::remove_var("ADMIN_USERNAME"); + + // Run seed + seed::seed_admin_user(&ctx.state.db).await?; + + // Verify admin user exists + let admin = ctx.state.db.get_user_by_username("admin").await? + .expect("Admin user should exist"); + + // Verify we can login with the provided password + let auth_helper = TestAuthHelper::new(ctx.app.clone()); + let token = auth_helper.login_user("admin", "testpass123").await; + assert!(!token.is_empty(), "Should be able to login with provided password"); + + // Clean up env var + std::env::remove_var("ADMIN_PASSWORD"); + + Ok(()) + }.await; + + std::env::remove_var("ADMIN_PASSWORD"); + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that subsequent runs don't duplicate admin user +#[tokio::test] +async fn test_admin_seed_does_not_duplicate_user() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + std::env::remove_var("ADMIN_PASSWORD"); + std::env::remove_var("ADMIN_USERNAME"); + + // Run seed first time + seed::seed_admin_user(&ctx.state.db).await?; + let first_admin = ctx.state.db.get_user_by_username("admin").await? + .expect("Admin should exist after first seed"); + + // Run seed second time + seed::seed_admin_user(&ctx.state.db).await?; + let second_admin = ctx.state.db.get_user_by_username("admin").await? + .expect("Admin should still exist after second seed"); + + // Verify same user (same ID and hash) + assert_eq!(first_admin.id, second_admin.id, "Should be the same user"); + assert_eq!( + first_admin.password_hash, second_admin.password_hash, + "Password should not have changed" + ); + + // Verify only one admin exists + let all_users = ctx.state.db.get_all_users().await?; + let admin_count = all_users.iter().filter(|u| u.username == "admin").count(); + assert_eq!(admin_count, 1, "Should only have one admin user"); + + Ok(()) + }.await; + + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that user can successfully login with generated credentials +#[tokio::test] +async fn test_admin_seed_allows_login() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Set known password for testing + std::env::set_var("ADMIN_PASSWORD", "logintest123"); + std::env::remove_var("ADMIN_USERNAME"); + + // Run seed + seed::seed_admin_user(&ctx.state.db).await?; + + // Attempt login + let auth_helper = TestAuthHelper::new(ctx.app.clone()); + let token = auth_helper.login_user("admin", "logintest123").await; + + // Verify token is not empty + assert!(!token.is_empty(), "Should receive valid JWT token"); + + // Verify token is valid by making authenticated request + let _response = auth_helper + .make_authenticated_request("GET", "/api/auth/me", None, &token) + .await; + + // If we get here without panicking, the authenticated request succeeded + + // Clean up env var + std::env::remove_var("ADMIN_PASSWORD"); + + Ok(()) + }.await; + + std::env::remove_var("ADMIN_PASSWORD"); + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that reset command generates new password and invalidates old one +#[tokio::test] +async fn test_reset_command_changes_password() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Create admin with known password + let _ = ctx.state.db.create_user(CreateUser { + username: "admin".to_string(), + email: "admin@readur.com".to_string(), + password: "oldpass123".to_string(), + role: Some(UserRole::Admin), + }).await?; + + // Get original password hash + let old_hash = ctx.state.db.get_user_by_username("admin") + .await? + .unwrap() + .password_hash; + + // Reset password with new one + std::env::set_var("ADMIN_PASSWORD", "newpass456"); + commands::reset_admin_password(&ctx.state.db).await?; + std::env::remove_var("ADMIN_PASSWORD"); + + // Get new password hash + let new_hash = ctx.state.db.get_user_by_username("admin") + .await? + .unwrap() + .password_hash; + + // Verify password changed + assert_ne!(old_hash, new_hash, "Password hash should have changed"); + + // Verify new password works + let auth_helper = TestAuthHelper::new(ctx.app.clone()); + let token = auth_helper.login_user("admin", "newpass456").await; + assert!(!token.is_empty(), "Should be able to login with new password"); + + // Note: We don't explicitly test that old password doesn't work because + // the auth helper panics on login failure. The fact that new password works + // and the hash changed is sufficient verification. + + Ok(()) + }.await; + + std::env::remove_var("ADMIN_PASSWORD"); + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that reset command uses provided ADMIN_PASSWORD +#[tokio::test] +async fn test_reset_command_uses_env_password() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Create admin + let _ = ctx.state.db.create_user(CreateUser { + username: "admin".to_string(), + email: "admin@readur.com".to_string(), + password: "initial123".to_string(), + role: Some(UserRole::Admin), + }).await?; + + // Reset with specific password + std::env::set_var("ADMIN_PASSWORD", "specific789"); + commands::reset_admin_password(&ctx.state.db).await?; + std::env::remove_var("ADMIN_PASSWORD"); + + // Verify can login with the specific password + let auth_helper = TestAuthHelper::new(ctx.app.clone()); + let token = auth_helper.login_user("admin", "specific789").await; + assert!(!token.is_empty(), "Should login with environment-specified password"); + + Ok(()) + }.await; + + std::env::remove_var("ADMIN_PASSWORD"); + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +} + +/// Test that reset command returns error for non-existent user +#[tokio::test] +async fn test_reset_command_fails_for_nonexistent_user() { + let ctx = TestContext::new().await; + let result: Result<()> = async { + // Don't create any admin user + + // Try to reset password for non-existent admin + std::env::remove_var("ADMIN_USERNAME"); // Use default "admin" + std::env::set_var("ADMIN_PASSWORD", "testpass123"); + + let reset_result = commands::reset_admin_password(&ctx.state.db).await; + + // Should return an error + assert!( + reset_result.is_err(), + "Reset should fail when user doesn't exist" + ); + + let error_message = reset_result.unwrap_err().to_string(); + assert!( + error_message.contains("not found") || error_message.contains("Admin user"), + "Error should indicate user not found, got: {}", + error_message + ); + + std::env::remove_var("ADMIN_PASSWORD"); + + Ok(()) + }.await; + + std::env::remove_var("ADMIN_PASSWORD"); + if let Err(e) = ctx.cleanup_and_close().await { + eprintln!("Warning: Test cleanup failed: {}", e); + } + result.unwrap(); +}