256 lines
8.0 KiB
Rust
256 lines
8.0 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"
|
|
);
|
|
}
|