cli: avoid data-dir initialization for version; create db parent dirs; redact generated passwords in CLI output
- Prevent 'nx9-wg version' from creating data directories by avoiding database initialization. - Create parent directories when an explicit --database path is provided. - Redact printed generated administrator passwords; announce file path or redact instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
commit
2ac6c81dfe
140 files changed
+31342
No files matched your search
@@ -0,0 +1,242 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user