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:
thakaresandCopilot committed 2026-08-16 16:26:24 +05:30
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"
);
}
@@ -0,0 +1,91 @@
//! Integration test suite for Backup and Restore engine.
use nx9_wg_api::backup::BackupService;
use nx9_wg_core::types::network::Network;
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use tempfile::tempdir;
use uuid::Uuid;
#[tokio::test]
async fn test_backup_create_verify_and_restore() {
let dir = tempdir().expect("create temp dir");
let active_db_path = dir.path().join("active.db");
let backup_dir = dir.path().join("backups");
let safety_dir = dir.path().join("safety");
let store = Store::connect(&active_db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
// Insert test record
let net = Network {
id: Uuid::new_v4(),
name: "test_lan".to_string(),
cidr: validate_cidr("10.50.0.0/24").unwrap(),
enabled: true,
description: Some("LAN subnet".to_string()),
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store.create_network(&net).await.expect("create network");
// Create Backup
let (meta, backup_file) = BackupService::create_backup(
&store,
&backup_dir,
Some("Test backup snapshot"),
"test_admin",
Some("127.0.0.1"),
)
.await
.expect("create backup");
assert!(backup_file.exists());
assert!(meta.size_bytes > 0);
assert!(!meta.checksum.is_empty());
// Verify Backup
let is_valid =
BackupService::verify_backup(&backup_file, Some(&meta.checksum)).expect("verify backup");
assert!(is_valid, "Backup file should be valid SQLite archive");
// List backups from store
let backups = store.list_backups().await.expect("list backups");
assert_eq!(backups.len(), 1);
assert_eq!(backups[0].id, meta.id);
// Modify active DB by adding another network
let net2 = Network {
id: Uuid::new_v4(),
name: "temporary_lan".to_string(),
cidr: validate_cidr("10.99.0.0/24").unwrap(),
enabled: true,
description: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store.create_network(&net2).await.expect("create net2");
assert_eq!(store.list_networks().await.unwrap().len(), 2);
// Restore Backup
BackupService::restore_backup(
&store,
&backup_file,
&active_db_path,
&safety_dir,
"test_admin",
None,
)
.await
.expect("restore backup");
// Reopen store to verify restored content
let restored_store = Store::connect(&active_db_path.to_string_lossy())
.await
.expect("reconnect store");
let restored_networks = restored_store.list_networks().await.expect("list restored");
assert_eq!(restored_networks.len(), 1);
assert_eq!(restored_networks[0].name, "test_lan");
}
@@ -0,0 +1,184 @@
//! Integration tests for Client Profiles REST API endpoints and resolver.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use ipnet::IpNet;
use nx9_wg_api::state::AppState;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::client_profile::{ClientProfile, ConnectionType, ResolvedClientProfile};
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_db::Store;
use std::str::FromStr;
use tower::ServiceExt;
use uuid::Uuid;
async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
let store = Store::connect_in_memory().await.unwrap();
store.migrate().await.unwrap();
let now = chrono::Utc::now().naive_utc();
let hash = nx9_wg_core::crypto::hash_password("adminpassword123").unwrap();
store.create_admin("admin", &hash).await.unwrap();
// Create session token
let session = nx9_wg_core::types::auth::Session {
id: "test-session-id-12345".to_string(),
admin_id: 1,
created_at: now,
expires_at: now + chrono::Duration::hours(24),
last_seen_at: Some(now),
ip_address: Some("127.0.0.1".to_string()),
user_agent: Some("test-agent".to_string()),
};
store.create_session(&session).await.unwrap();
let (srv_priv, srv_pub) = generate_keypair();
let (peer_priv, peer_pub) = generate_keypair();
let interface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
private_key: srv_priv,
public_key: srv_pub,
listen_port: 51820,
address_v4: IpNet::from_str("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: Some("1.1.1.1".to_string()),
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
store.create_interface(&interface).await.unwrap();
let peer = Peer {
id: Uuid::new_v4(),
interface_id: interface.id,
name: "test-mobile-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: peer_pub,
private_key: Some(peer_priv),
preshared_key: None,
endpoint: None,
allowed_ips: "10.0.0.2/32".to_string(),
server_allowed_ips: None,
address_v4: Some(IpNet::from_str("10.0.0.2/32").unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
store.create_peer(&peer).await.unwrap();
let state = AppState::new(store);
let app = nx9_wg_api::routes::build_api_router(state.clone());
(app, state, session.id, interface, peer)
}
#[tokio::test]
async fn test_client_profiles_endpoints() {
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
// 1. List client profiles
let req = Request::builder()
.uri("/api/v1/client-profiles")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let profiles: Vec<ClientProfile> = serde_json::from_slice(&body).unwrap();
assert!(profiles.len() >= 10);
// 2. List distinct providers
let req = Request::builder()
.uri("/api/v1/client-profiles/providers")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let providers: Vec<String> = serde_json::from_slice(&body).unwrap();
assert!(providers.contains(&"tmobile".to_string()));
assert!(providers.contains(&"starlink".to_string()));
// 3. List device categories
let req = Request::builder()
.uri("/api/v1/client-profiles/devices")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 4. Resolve client profile via POST
let resolve_body = serde_json::json!({
"connection": "mobile",
"device": "android",
"nat": "cgnat"
});
let req = Request::builder()
.method("POST")
.uri("/api/v1/client-profiles/resolve")
.header("Cookie", format!("nx9_session={session_id}"))
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&resolve_body).unwrap()))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let resolved: ResolvedClientProfile = serde_json::from_slice(&body).unwrap();
assert_eq!(resolved.mtu, 1280);
assert_eq!(resolved.connection_type, ConnectionType::Mobile);
// 5. Download peer .conf with mobile profile parameters
let req = Request::builder()
.uri(format!(
"/api/v1/peers/{}/config?connection=mobile&device=android",
peer.id
))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let conf_str = String::from_utf8(body.to_vec()).unwrap();
assert!(conf_str.contains("MTU = 1280"));
assert!(conf_str.contains("PersistentKeepalive = 25"));
// 6. Get QR code with CGNAT profile parameters
let req = Request::builder()
.uri(format!("/api/v1/peers/{}/qr?nat=cgnat", peer.id))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
}
@@ -0,0 +1,78 @@
//! Integration test suite for Reconciliation Engine.
use nx9_wg_api::reconciliation::ReconciliationEngine;
use nx9_wg_api::state::AppState;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::Interface;
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
use std::sync::Arc;
use tempfile::tempdir;
use uuid::Uuid;
#[tokio::test]
async fn test_reconciliation_engine_drift_detection_and_apply() {
let dir = tempdir().expect("create temp dir");
let db_path = dir.path().join("reconcile.db");
let store = Store::connect(&db_path.to_string_lossy())
.await
.expect("connect to db");
store.migrate().await.expect("run migrations");
let state = AppState::new(store.clone());
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler = ReconciliationEngine::new(state, wg_engine.clone(), net_engine.clone());
// 1. Create desired interface in SQLite
let (priv_key, pub_key) = generate_keypair();
let iface = Interface {
id: Uuid::new_v4(),
name: "wg0".to_string(),
private_key: priv_key,
public_key: pub_key,
listen_port: 51820,
address_v4: validate_cidr("10.0.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: chrono::Utc::now().naive_utc(),
updated_at: chrono::Utc::now().naive_utc(),
};
store
.create_interface(&iface)
.await
.expect("create interface");
// 2. Compute plan: should detect missing wg0 in kernel
let plan = reconciler.plan().await.expect("compute plan");
assert!(plan.has_drift);
assert_eq!(plan.interface_changes, 1);
assert!(!plan.actions.is_empty());
// 3. Apply reconciliation
let report = reconciler.apply().await.expect("apply plan");
assert!(report.success);
assert!(report.executed_actions > 0);
// 4. Verify live WireGuard interface state
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap();
assert!(live_stats.is_some());
let stats = live_stats.unwrap();
assert_eq!(stats.name, "wg0");
assert_eq!(stats.listen_port, 51820);
// 5. Verify audit event was logged
let audits = store
.list_audit_events(&nx9_wg_db::AuditFilter::default(), 10, 0)
.await
.expect("list audits");
assert!(!audits.is_empty());
}
+236
View File
@@ -0,0 +1,236 @@
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_core::config::AppConfig;
use nx9_wg_db::Store;
use serde_json::{Value, json};
use tower::ServiceExt;
async fn setup_test_app() -> (axum::Router, String) {
let store = Store::connect_in_memory().await.expect("connect in-memory");
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 state = AppState::new(store);
let app = build_api_router(state.clone());
// Login to get session ID
let login_req = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"username": "admin",
"password": "AdminSecret123!"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(login_req).await.expect("login request");
assert_eq!(resp.status(), StatusCode::OK);
let cookie_header = resp
.headers()
.get(header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
(app, session_cookie)
}
#[tokio::test]
async fn test_public_health_and_version_endpoints() {
let (app, _) = setup_test_app().await;
// Health
let req = Request::builder()
.uri("/api/v1/system/health")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "healthy");
assert_eq!(val["database"], "connected");
// Version
let req = Request::builder()
.uri("/api/v1/system/version")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["name"], "nx9-wg");
}
#[tokio::test]
async fn test_protected_route_unauthenticated_rejection() {
let (app, _) = setup_test_app().await;
// Request protected route without auth
let req = Request::builder()
.uri("/api/v1/system")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_interfaces_and_peers_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// 1. Create interface
let create_iface_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg0",
"listen_port": 51820,
"address_v4": "10.0.0.1/24",
"dns": "1.1.1.1"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let iface_val: Value = serde_json::from_slice(&body).unwrap();
let iface_id = iface_val["id"].as_str().unwrap();
assert_eq!(iface_val["name"], "wg0");
// 2. List interfaces
let list_req = Request::builder()
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 3. Create peer under interface
let create_peer_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "laptop-alice",
"peer_type": "road_warrior",
"profile": "full_tunnel",
"allowed_ips": "10.0.0.2/32"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
let peer_id = peer_val["id"].as_str().unwrap();
assert_eq!(peer_val["name"], "laptop-alice");
// 4. Disable peer
let disable_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/peers/{peer_id}/disable"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(disable_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 5. Get peer and verify state
let get_peer_req = Request::builder()
.uri(format!("/api/v1/peers/{peer_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(peer_val["state"], "disabled");
// 6. Delete interface (cascades peer)
let del_iface_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{iface_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_networks_and_firewall_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// Create network
let net_req = Request::builder()
.method("POST")
.uri("/api/v1/networks")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Management Network",
"cidr": "10.10.0.0/16",
"description": "Internal management"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(net_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Create firewall rule
let fw_req = Request::builder()
.method("POST")
.uri("/api/v1/firewall/rules")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Allow HTTPS",
"direction": "in",
"action": "accept",
"protocol": "tcp",
"destination_port": 443,
"priority": 10
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(fw_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let rule_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(rule_val["name"], "Allow HTTPS");
assert_eq!(rule_val["priority"], 10);
}
@@ -0,0 +1,94 @@
//! Integration tests for embedded Web UI SPA and static asset endpoints.
use axum::body::to_bytes;
use axum::http::{Request, StatusCode};
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_db::Store;
use tower::ServiceExt;
#[tokio::test]
async fn test_ui_spa_index_and_stylesheet_endpoints() {
let store = Store::connect_in_memory().await.expect("connect store");
store.migrate().await.expect("migrate store");
let state = AppState::new(store);
let app = build_api_router(state);
// 1. Test GET / (Root SPA Index)
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=utf-8"
);
let body_bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
let html = String::from_utf8_lossy(&body_bytes);
assert!(html.contains("nx9-wg — Native WireGuard Appliance"));
assert!(html.contains("NX9"));
assert!(html.contains("id=\"app-layout\""));
assert!(html.contains("id=\"sidebar\""));
assert!(html.contains("Dashboard"));
assert!(html.contains("Peers"));
assert!(html.contains("Diagnostics"));
assert!(html.contains("Administrator"));
// 2. Test GET /ui (Alias)
let res_ui = app
.clone()
.oneshot(
Request::builder()
.uri("/ui")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res_ui.status(), StatusCode::OK);
// 3. Test GET /assets/style.css (Compiled CSS)
let res_css = app
.oneshot(
Request::builder()
.uri("/assets/style.css")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("execute request");
assert_eq!(res_css.status(), StatusCode::OK);
assert_eq!(
res_css
.headers()
.get(axum::http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap(),
"text/css; charset=utf-8"
);
let css_bytes = to_bytes(res_css.into_body(), 1024 * 1024).await.unwrap();
let css = String::from_utf8_lossy(&css_bytes);
assert!(css.contains("--bg-base: #0d1117;"));
assert!(css.contains("[data-theme=\"dark\"]"));
assert!(css.contains("[data-theme=\"light\"]"));
assert!(css.contains(".status-pass"));
assert!(css.contains(".status-fail"));
assert!(css.contains("@media (max-width: 768px)"));
}
@@ -0,0 +1,405 @@
use chrono::{Duration, Utc};
use nx9_wg_api::{AppState, DiagnosticsService, IpAllocator, ReconciliationEngine};
use nx9_wg_core::types::diagnostics::DiagnosticSubsystem;
use nx9_wg_core::types::firewall::{
FirewallAction, FirewallDirection, FirewallProtocol, FirewallRule,
};
use nx9_wg_core::types::network::Network;
use nx9_wg_core::types::wireguard::{
Interface, Peer, PeerProfile, PeerState, PeerType, WireGuardPrivateKey, WireGuardPublicKey,
};
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
use std::net::IpAddr;
use std::sync::Arc;
use uuid::Uuid;
async fn setup_test_context() -> (
AppState,
Arc<SimulatedWireGuardEngine>,
Arc<SimulatedNetworkEngine>,
Arc<ReconciliationEngine>,
) {
let store = Store::connect_in_memory().await.expect("connect DB");
store.migrate().await.expect("migrate DB");
let state = AppState::new(store);
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler = Arc::new(ReconciliationEngine::new(
state.clone(),
wg_engine.clone(),
net_engine.clone(),
));
(state, wg_engine, net_engine, reconciler)
}
#[tokio::test]
async fn test_automatic_ip_allocation() {
let (state, _, _, _) = setup_test_context().await;
let now = Utc::now().naive_utc();
let net_id = Uuid::new_v4();
let network = Network {
id: net_id,
name: "Test-V4-Subnet".to_string(),
cidr: "10.50.0.0/24".parse().unwrap(),
enabled: true,
description: None,
created_at: now,
updated_at: now,
};
state
.store
.create_network(&network)
.await
.expect("create net");
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg50".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51850,
address_v4: "10.50.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
// First allocation: 10.50.0.1 is interface -> next available is 10.50.0.2/32
let ip1 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
.await
.expect("allocate ip1");
assert_eq!(ip1.to_string(), "10.50.0.2/32");
// Create a peer with this allocated IP
let peer1 = Peer {
id: Uuid::new_v4(),
interface_id: iface_id,
name: "peer-1".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"peer1pubkey12345678901234567890123456789012=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: ip1.to_string(),
server_allowed_ips: None,
address_v4: Some(ip1),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state.store.create_peer(&peer1).await.expect("create peer1");
// Second allocation: next should be 10.50.0.3/32
let ip2 = IpAllocator::allocate_next_ip(&state.store, &network, Some(&iface), None)
.await
.expect("allocate ip2");
assert_eq!(ip2.to_string(), "10.50.0.3/32");
// List available IPs: first should be 10.50.0.3
let available = IpAllocator::list_available_ips(&state.store, &network, Some(&iface), 5)
.await
.expect("list available");
assert_eq!(available.len(), 5);
assert_eq!(available[0], "10.50.0.3".parse::<IpAddr>().unwrap());
assert_eq!(available[1], "10.50.0.4".parse::<IpAddr>().unwrap());
// List allocations: should show peer1
let allocs = IpAllocator::list_allocations(&state.store, &network)
.await
.expect("list allocs");
assert_eq!(allocs.len(), 1);
assert_eq!(allocs[0].ip_address, "10.50.0.2/32");
assert_eq!(allocs[0].peer_name.as_deref(), Some("peer-1"));
}
#[tokio::test]
async fn test_peer_expiration_lifecycle() {
let (state, wg_engine, _net_engine, reconciler) = setup_test_context().await;
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg60".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51860,
address_v4: "10.60.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
// Peer with expiration in the past
let expired_peer_id = Uuid::new_v4();
let expired_peer = Peer {
id: expired_peer_id,
interface_id: iface_id,
name: "expired-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active, // marked active initially
public_key: WireGuardPublicKey::new(
"expiredpubkey123456789012345678901234567890=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.60.0.5/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.60.0.5/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: Some(now - Duration::hours(1)), // expired 1 hour ago
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state
.store
.create_peer(&expired_peer)
.await
.expect("create peer");
// Active peer without expiration
let active_peer_id = Uuid::new_v4();
let active_peer = Peer {
id: active_peer_id,
interface_id: iface_id,
name: "active-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"activepubkey1234567890123456789012345678901=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.60.0.6/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.60.0.6/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: Some(now + Duration::days(30)),
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state
.store
.create_peer(&active_peer)
.await
.expect("create peer");
// Run reconciliation sweep
let swept = reconciler.sweep_expired_peers().await.expect("sweep");
assert_eq!(swept, 1);
// Verify expired_peer transitioned to Expired
let p1 = state
.store
.get_peer(expired_peer_id)
.await
.expect("get")
.unwrap();
assert_eq!(p1.state, PeerState::Expired);
// Verify active_peer remains Active
let p2 = state
.store
.get_peer(active_peer_id)
.await
.expect("get")
.unwrap();
assert_eq!(p2.state, PeerState::Active);
// Reconcile apply ensures only active peers are synced to WireGuard kernel engine
let rep = reconciler.apply().await.expect("apply");
assert!(rep.success);
let stats = wg_engine
.get_interface_stats("wg60")
.await
.unwrap()
.unwrap();
// Only active peer should be live in interface
assert_eq!(stats.peers.len(), 1);
assert_eq!(stats.peers[0].public_key, active_peer.public_key.as_str());
}
#[tokio::test]
async fn test_peer_firewall_and_port_ranges() {
let (state, _, net_engine, reconciler) = setup_test_context().await;
let now = Utc::now().naive_utc();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg70".to_string(),
private_key: WireGuardPrivateKey::new(
"cGFzc3dvcmRkZXZlbG9wbWVudGtleTEyMzQ1Njc4OTAxMg==".to_string(),
),
public_key: WireGuardPublicKey::new(
"cHVibGlja2V5ZGV2ZWxvcG1lbnRrZXkxMjM0NTY3ODkwMTI=".to_string(),
),
listen_port: 51870,
address_v4: "10.70.0.1/24".parse().unwrap(),
address_v6: None,
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: now,
updated_at: now,
};
state
.store
.create_interface(&iface)
.await
.expect("create iface");
let peer_id = Uuid::new_v4();
let peer = Peer {
id: peer_id,
interface_id: iface_id,
name: "dev-peer".to_string(),
peer_type: PeerType::RoadWarrior,
state: PeerState::Active,
public_key: WireGuardPublicKey::new(
"devpeerpubkey1234567890123456789012345678901=".to_string(),
),
private_key: None,
preshared_key: None,
endpoint: None,
allowed_ips: "10.70.0.10/32".to_string(),
server_allowed_ips: None,
address_v4: Some("10.70.0.10/32".parse().unwrap()),
address_v6: None,
dns: None,
mtu: None,
persistent_keepalive: None,
profile: PeerProfile::FullTunnel,
expires_at: None,
last_handshake_at: None,
created_at: now,
updated_at: now,
};
state.store.create_peer(&peer).await.expect("create peer");
// Peer-specific rule with multi-port and TCP/UDP protocol
let rule = FirewallRule {
id: Uuid::new_v4(),
name: "Allow Dev Ports".to_string(),
interface_id: Some(iface_id),
peer_id: Some(peer_id),
direction: FirewallDirection::Forward,
action: FirewallAction::Accept,
protocol: FirewallProtocol::TcpUdp,
source: None,
destination: None,
source_port: None,
destination_port: None,
port_range: Some("8000-8100".to_string()),
priority: 10,
enabled: true,
description: Some("Peer port range".to_string()),
created_at: now,
updated_at: now,
};
state
.store
.create_firewall_rule(&rule)
.await
.expect("create rule");
// Apply reconciliation to compile ruleset
reconciler.apply().await.expect("apply");
let ruleset = net_engine
.get_active_nftables_ruleset()
.await
.expect("get ruleset");
assert!(ruleset.contains("table inet nx9_wg"));
// Resolved peer IP 10.70.0.10, protocol meta l4proto { tcp, udp }, and port range 8000-8100
assert!(ruleset.contains("ip saddr 10.70.0.10"));
assert!(ruleset.contains("meta l4proto { tcp, udp }"));
assert!(ruleset.contains("th dport 8000-8100 accept"));
}
#[tokio::test]
async fn test_native_diagnostics_subsystem() {
let (state, wg_engine, net_engine, reconciler) = setup_test_context().await;
let diag = DiagnosticsService::new(state, wg_engine, net_engine, reconciler);
let all_reports = diag.diagnose_all().await.expect("diagnose all");
assert!(!all_reports.is_empty());
let sys_report = diag
.run_diagnostic(DiagnosticSubsystem::System, None)
.await
.expect("diag system");
assert_eq!(sys_report.len(), 1);
assert_eq!(sys_report[0].subsystem, "system");
let fwd_report = diag
.run_diagnostic(DiagnosticSubsystem::Forwarding, None)
.await
.expect("diag fwd");
assert_eq!(fwd_report.len(), 1);
assert_eq!(fwd_report[0].subsystem, "forwarding");
}