feat(server): allow for random admin password generation
This commit is contained in:
parent
63aa7347a9
commit
3486f0fdf8
19
.env.example
19
.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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod reset_admin;
|
||||
|
||||
pub use reset_admin::reset_admin_password;
|
||||
|
|
@ -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!();
|
||||
}
|
||||
|
|
@ -319,4 +319,48 @@ impl Database {
|
|||
auth_provider: row.get::<String, _>("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<User> {
|
||||
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::<String, _>("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::<String, _>("auth_provider").try_into().unwrap_or(AuthProvider::Local),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod auth;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod db_guardrails_simple;
|
||||
|
|
|
|||
53
src/main.rs
53
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<Commands>,
|
||||
}
|
||||
|
||||
#[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));
|
||||
|
|
|
|||
63
src/seed.rs
63
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
Loading…
Reference in New Issue