Files
nx9-wg/crates/nx9-wg-api/tests/test_auth_subsystem.rs
2026-08-18 17:32:56 +05:30

307 lines
9.5 KiB
Rust

//! 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"
);
}