//! Integration tests for Phase 2: Authentication, Admin Bootstrap, Rate Limiting, and Security. use chrono::{Duration, Utc}; use nx9_wg_api::auth::{AuthService, BootstrapOptions, ResolvedSource, bootstrap_admin}; use nx9_wg_core::config::AppConfig; use nx9_wg_db::Store; use tempfile::NamedTempFile; #[tokio::test] async fn test_admin_bootstrap_all_sources_and_rejection() { let config = AppConfig::default(); // 1. Bootstrap with explicit CLI password let store = Store::connect_in_memory().await.expect("connect"); store.migrate().await.expect("migrate"); let opts = BootstrapOptions { admin_username: Some("custom_admin".to_string()), cli_password: Some("SecurePassword123!".to_string()), ..Default::default() }; let res = bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap cli"); assert_eq!(res.source, ResolvedSource::CliArgument); assert_eq!(res.admin.username, "custom_admin"); // Re-bootstrap must fail let re_bootstrap = bootstrap_admin(&store, &config, &opts).await; assert!(re_bootstrap.is_err(), "re-bootstrap must be rejected"); // 2. Bootstrap from password file let store2 = Store::connect_in_memory().await.expect("connect"); store2.migrate().await.expect("migrate"); let tmp_file = NamedTempFile::new().expect("temp file"); std::fs::write(tmp_file.path(), "FileSecretPass999!\n").expect("write secret"); let opts2 = BootstrapOptions { password_file: Some(tmp_file.path().to_str().unwrap().to_string()), ..Default::default() }; let res2 = bootstrap_admin(&store2, &config, &opts2) .await .expect("bootstrap file"); assert_eq!(res2.source, ResolvedSource::PasswordFile); assert_eq!(res2.admin.username, "admin"); // 3. Bootstrap from generated password let store3 = Store::connect_in_memory().await.expect("connect"); store3.migrate().await.expect("migrate"); let gen_file = NamedTempFile::new().expect("gen file"); let opts3 = BootstrapOptions { generate_password: true, write_password_file: Some(gen_file.path().to_str().unwrap().to_string()), ..Default::default() }; let res3 = bootstrap_admin(&store3, &config, &opts3) .await .expect("bootstrap gen"); assert_eq!(res3.source, ResolvedSource::Generated); assert!(res3.generated_plaintext.is_some()); let gen_pw = res3.generated_plaintext.unwrap(); let written = std::fs::read_to_string(gen_file.path()).expect("read gen"); assert_eq!(written, gen_pw); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let perms = std::fs::metadata(gen_file.path()) .expect("metadata") .permissions(); assert_eq!( perms.mode() & 0o777, 0o600, "Password file permissions must be 0600" ); } } #[tokio::test] async fn test_auth_service_login_and_rate_limiting() { let store = Store::connect_in_memory().await.expect("connect"); store.migrate().await.expect("migrate"); let config = AppConfig::default(); let opts = BootstrapOptions { cli_password: Some("AdminSecret123!".to_string()), ..Default::default() }; bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap"); let auth = AuthService::new(store); // Successful login let session = auth .login( "admin", "AdminSecret123!", Some("192.168.1.50"), Some("TestBrowser/1.0"), ) .await .expect("successful login"); assert_eq!(session.admin_id, 1); assert_eq!(session.ip_address.as_deref(), Some("192.168.1.50")); // Authenticate with valid session let authenticated = auth .authenticate_session(&session.id) .await .expect("authenticate session"); assert_eq!(authenticated.id, session.id); // Wrong password login fails let fail = auth .login("admin", "WrongPass123!", Some("192.168.1.50"), None) .await; assert!(fail.is_err(), "wrong password must fail"); // Test rate-limit lockout after 5 failed attempts from same IP let attacker_ip = "10.0.0.99"; for _ in 0..5 { let _ = auth .login("admin", "WrongPass123!", Some(attacker_ip), None) .await; } // 6th attempt must be rejected with rate limit lockout even with correct password let lockout = auth .login("admin", "AdminSecret123!", Some(attacker_ip), None) .await; assert!(lockout.is_err()); let err_msg = lockout.unwrap_err().to_string(); assert!( err_msg.contains("rate limited") || err_msg.contains("Too many failed"), "error should indicate rate limit lockout: {err_msg}" ); // Login from another IP should still succeed let other_ip_login = auth .login("admin", "AdminSecret123!", Some("192.168.1.60"), None) .await; assert!( other_ip_login.is_ok(), "different IP must not be locked out" ); } #[tokio::test] async fn test_auth_service_password_change_invalidates_sessions() { let store = Store::connect_in_memory().await.expect("connect"); store.migrate().await.expect("migrate"); let config = AppConfig::default(); let opts = BootstrapOptions { cli_password: Some("OriginalPassword123!".to_string()), ..Default::default() }; bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap"); let auth = AuthService::new(store.clone()); // Create two active sessions let s1 = auth .login("admin", "OriginalPassword123!", Some("1.1.1.1"), None) .await .expect("login 1"); let s2 = auth .login("admin", "OriginalPassword123!", Some("2.2.2.2"), None) .await .expect("login 2"); assert!(auth.authenticate_session(&s1.id).await.is_ok()); assert!(auth.authenticate_session(&s2.id).await.is_ok()); // Change password auth.change_password("NewRotatedPassword456!", Some("1.1.1.1")) .await .expect("change password"); // Both previous sessions must now be rejected assert!( auth.authenticate_session(&s1.id).await.is_err(), "s1 must be invalidated" ); assert!( auth.authenticate_session(&s2.id).await.is_err(), "s2 must be invalidated" ); // Old password must fail; new password must succeed assert!( auth.login("admin", "OriginalPassword123!", None, None) .await .is_err() ); let new_login = auth .login("admin", "NewRotatedPassword456!", None, None) .await .expect("new login"); assert!(auth.authenticate_session(&new_login.id).await.is_ok()); } #[tokio::test] async fn test_auth_service_api_tokens() { let store = Store::connect_in_memory().await.expect("connect"); store.migrate().await.expect("migrate"); let config = AppConfig::default(); let opts = BootstrapOptions { cli_password: Some("AdminSecret123!".to_string()), ..Default::default() }; bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap"); let auth = AuthService::new(store); // Create API token let (token_meta, raw_token) = auth .create_api_token( "Terraform Runner", Some(Utc::now().naive_utc() + Duration::days(7)), Some("10.0.0.1"), ) .await .expect("create token"); assert!(raw_token.starts_with("nx9_")); assert_eq!(token_meta.name, "Terraform Runner"); // Authenticate with raw token let authenticated = auth .authenticate_token(&raw_token) .await .expect("authenticate token"); assert_eq!(authenticated.id, token_meta.id); // Revoke token auth.revoke_api_token(&token_meta.id, Some("10.0.0.1")) .await .expect("revoke"); // Authenticating revoked token must fail assert!( auth.authenticate_token(&raw_token).await.is_err(), "revoked token must fail authentication" ); } #[tokio::test] async fn test_auth_service_logout_invalidates_session_and_is_idempotent() { let store = Store::connect_in_memory().await.expect("connect"); store.migrate().await.expect("migrate"); let config = AppConfig::default(); let opts = BootstrapOptions { cli_password: Some("AdminSecret123!".to_string()), ..Default::default() }; bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap"); let auth = AuthService::new(store); // Create 2 sessions let s1 = auth .login("admin", "AdminSecret123!", Some("10.0.0.1"), None) .await .expect("login 1"); let s2 = auth .login("admin", "AdminSecret123!", Some("10.0.0.2"), None) .await .expect("login 2"); assert!(auth.authenticate_session(&s1.id).await.is_ok()); assert!(auth.authenticate_session(&s2.id).await.is_ok()); // Logout session 1 auth.logout(&s1.id, Some("10.0.0.1")) .await .expect("logout s1"); // Session 1 is invalidated; Session 2 remains valid assert!( auth.authenticate_session(&s1.id).await.is_err(), "s1 must be rejected after logout" ); assert!( auth.authenticate_session(&s2.id).await.is_ok(), "s2 must remain valid" ); // Repeated logout of s1 is safe/idempotent assert!( auth.logout(&s1.id, Some("10.0.0.1")).await.is_ok(), "repeated logout must be safe and idempotent" ); }