release: NX9-WG v1.0.0

This commit is contained in:
thakares committed 2026-08-18 17:32:56 +05:30
1 parent c8a9b7cde6
commit 4dfe42fe68
42 files changed
+4689 -336

No files matched your search

@@ -253,3 +253,54 @@ async fn test_auth_service_api_tokens() {
"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"
);
}
@@ -79,6 +79,10 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
updated_at: now,
};
store.create_peer(&peer).await.unwrap();
store
.set_setting("server_endpoint", "vpn.example.com", false)
.await
.unwrap();
let state = AppState::new(store);
let app = nx9_wg_api::routes::build_api_router(state.clone());
@@ -88,7 +92,7 @@ async fn setup_test_app() -> (axum::Router, AppState, String, Interface, Peer) {
#[tokio::test]
async fn test_client_profiles_endpoints() {
let (app, _state, session_id, _iface, peer) = setup_test_app().await;
let (app, state, session_id, interface, peer) = setup_test_app().await;
// 1. List client profiles
let req = Request::builder()
@@ -181,4 +185,67 @@ async fn test_client_profiles_endpoints() {
.unwrap();
let qr_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
// 7. Delete server_endpoint setting and verify config export fails with actionable error
state.store.delete_setting("server_endpoint").await.unwrap();
let req = Request::builder()
.uri(format!("/api/v1/peers/{}/config", 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::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
err_json["error"]["message"]
.as_str()
.unwrap()
.contains("No reachable WireGuard server endpoint is configured")
);
// 8. With query server_endpoint parameter, export succeeds even without DB setting
let req = Request::builder()
.uri(format!(
"/api/v1/peers/{}/config?server_endpoint=custom.vpn.io:51820",
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("Endpoint = custom.vpn.io:51820"));
// 9. Overlapping server-side AllowedIPs rejection
let overlap_peer = serde_json::json!({
"name": "overlapping-peer",
"peer_type": "road_warrior",
"address_v4": "10.0.0.2/32"
});
let req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{}/peers", interface.id))
.header("Cookie", format!("nx9_session={session_id}"))
.header("Content-Type", "application/json")
.body(Body::from(serde_json::to_vec(&overlap_peer).unwrap()))
.unwrap();
let res = app.clone().oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
err_json["error"]["message"]
.as_str()
.unwrap()
.contains("overlaps with active peer")
);
}
@@ -19,7 +19,7 @@ use nx9_wg_core::types::network::Route;
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_core::validation::validate_cidr;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
use nx9_wireguard::{SimulatedWireGuardEngine, WireGuardEngine};
use std::sync::Arc;
use tempfile::{TempDir, tempdir};
@@ -399,3 +399,187 @@ async fn test_reconciliation_status_lifecycle_and_multi_cycle_idempotency() {
assert!(!plan.has_drift, "Cycle {cycle} plan must show zero drift");
}
}
#[tokio::test]
async fn test_reconciliation_report_schema_and_json_contract() {
use nx9_wg_api::reconciliation::{ReconciliationReport, ReconciliationStatus};
let report = ReconciliationReport {
success: true,
status: ReconciliationStatus::Converged,
executed_actions: 3,
failed_actions: 0,
details: vec![
"Synchronized interface 'wg0' with 5 peers".to_string(),
"Synchronized 1 routing entries".to_string(),
"Synchronized 0 firewall rules into table inet nx9_wg (NAT: true)".to_string(),
],
};
let json_val = serde_json::to_value(&report).unwrap();
assert_eq!(json_val["success"], true);
assert_eq!(json_val["status"], "converged");
assert_eq!(json_val["executed_actions"], 3);
assert_eq!(json_val["failed_actions"], 0);
assert!(json_val["details"].is_array());
assert_eq!(json_val["details"].as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn test_reconciliation_nftables_canonical_drift_and_kernel_handle_tolerance() {
let (_dir, store, _state, _wg_engine, net_engine, reconciler) = setup_test_env().await;
// Add firewall rule in SQLite
let fw = FirewallRule {
id: Uuid::new_v4(),
name: "allow-https".to_string(),
interface_id: None,
peer_id: None,
direction: FirewallDirection::In,
source: None,
destination: None,
protocol: FirewallProtocol::Tcp,
source_port: None,
destination_port: Some(443),
port_range: None,
action: FirewallAction::Accept,
priority: 50,
enabled: true,
description: None,
created_at: Utc::now().naive_utc(),
updated_at: Utc::now().naive_utc(),
};
store.create_firewall_rule(&fw).await.unwrap();
// 1. Initial Plan should detect drift
let plan = reconciler.plan().await.unwrap();
assert!(plan.has_drift);
assert_eq!(plan.firewall_changes, 1);
// 2. Apply should converge
let report = reconciler.apply().await.unwrap();
assert!(report.success);
assert_eq!(
report.status,
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
);
assert_eq!(report.failed_actions, 0);
// 3. Post-apply verify: exactly 0 drift
let plan_after = reconciler.plan().await.unwrap();
assert!(!plan_after.has_drift);
assert_eq!(plan_after.firewall_changes, 0);
// 4. Simulate kernel returning ruleset with handles and tabs
let simulated_kernel_output_with_handles = r#"table inet nx9_wg {
chain input {
type filter hook input priority filter; policy accept;
ct state established,related accept # handle 46
iifname "lo" accept # handle 1
tcp dport 443 accept # handle 10
}
chain forward {
type filter hook forward priority filter; policy accept;
ct state established,related accept # handle 4
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
}
}
"#;
// Set simulated ruleset to text containing kernel handles
net_engine
.sync_firewall(std::slice::from_ref(&fw), false, &[])
.await
.unwrap();
// Directly test drift function against simulated kernel handles
let expected = nx9_wg_network::NftablesRulesetBuilder::build(&[fw], false, &[]);
assert!(
!nx9_wg_network::has_nftables_drift(&expected, simulated_kernel_output_with_handles),
"Ruleset with handles must not trigger false drift"
);
}
#[tokio::test]
async fn test_interface_address_and_mtu_drift_lifecycle() {
let (_dir, store, _state, wg_engine, _net_engine, reconciler) = setup_test_env().await;
let (priv_key, pub_key) = generate_keypair();
let iface_id = Uuid::new_v4();
let iface = Interface {
id: iface_id,
name: "wg0".to_string(),
private_key: priv_key,
public_key: pub_key.clone(),
listen_port: 51820,
address_v4: validate_cidr("10.100.0.1/24").unwrap(),
address_v6: Some(validate_cidr("fd00::1/64").unwrap()),
mtu: Some(1420),
dns: None,
enabled: true,
pre_up: None,
post_up: None,
pre_down: None,
post_down: None,
created_at: Utc::now().naive_utc(),
updated_at: Utc::now().naive_utc(),
};
store.create_interface(&iface).await.unwrap();
// 1. Initially, interface does not exist in wg_engine -> plan reports create_interface drift
let plan = reconciler.plan().await.unwrap();
assert!(plan.has_drift);
assert_eq!(plan.interface_changes, 1);
assert_eq!(plan.actions[0].action_type, "create_interface");
// 2. Apply initial sync -> interface is created and synchronized
let report = reconciler.apply().await.unwrap();
assert!(report.success);
assert_eq!(
report.status,
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
);
// 3. Post-apply plan must have 0 drift
let plan_after = reconciler.plan().await.unwrap();
assert!(!plan_after.has_drift);
assert_eq!(plan_after.interface_changes, 0);
// 4. Manually strip IPv4 address from live interface to simulate kernel address drop
let mut stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
stats.addresses = vec!["fd00::1/64".to_string()]; // IPv4 missing
// Sync altered stats
wg_engine
.sync_interface(
&Interface {
address_v4: validate_cidr("10.99.99.99/24").unwrap(), // different
..iface.clone()
},
&[],
)
.await
.unwrap();
// 5. Plan MUST detect the missing/mismatched IPv4 address as drift
let plan_drift = reconciler.plan().await.unwrap();
assert!(plan_drift.has_drift);
assert_eq!(plan_drift.interface_changes, 1);
assert_eq!(plan_drift.actions[0].action_type, "update_interface");
assert!(plan_drift.actions[0].description.contains("IPv4 address"));
// 6. Apply reconciliation -> restores correct addresses
let report2 = reconciler.apply().await.unwrap();
assert!(report2.success);
assert_eq!(
report2.status,
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
);
// 7. Final plan reports 0 drift
let final_plan = reconciler.plan().await.unwrap();
assert!(!final_plan.has_drift);
assert_eq!(final_plan.interface_changes, 0);
}
+175
View File
@@ -234,3 +234,178 @@ async fn test_networks_and_firewall_rest_lifecycle() {
assert_eq!(rule_val["name"], "Allow HTTPS");
assert_eq!(rule_val["priority"], 10);
}
#[tokio::test]
async fn test_list_all_peers_collection_endpoint() {
let (app, cookie) = setup_test_app().await;
// 1. Verify unauthenticated GET /api/v1/peers returns 401 Unauthorized
let unauth_req = Request::builder()
.uri("/api/v1/peers")
.body(Body::empty())
.unwrap();
let unauth_resp = app.clone().oneshot(unauth_req).await.unwrap();
assert_eq!(unauth_resp.status(), StatusCode::UNAUTHORIZED);
// 2. Create first interface (wg0)
let iface0_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.100.0.1/24"
})
.to_string(),
))
.unwrap();
let iface0_resp = app.clone().oneshot(iface0_req).await.unwrap();
assert_eq!(iface0_resp.status(), StatusCode::OK);
let iface0_val: Value =
serde_json::from_slice(&to_bytes(iface0_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface0_id = iface0_val["id"].as_str().unwrap();
// 3. Create second interface (wg1)
let iface1_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg1",
"listen_port": 51821,
"address_v4": "10.200.0.1/24"
})
.to_string(),
))
.unwrap();
let iface1_resp = app.clone().oneshot(iface1_req).await.unwrap();
assert_eq!(iface1_resp.status(), StatusCode::OK);
let iface1_val: Value =
serde_json::from_slice(&to_bytes(iface1_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface1_id = iface1_val["id"].as_str().unwrap();
// 4. Create 2 peers under wg0
let peer_alice_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-alice",
"allowed_ips": "10.100.0.2/32"
})
.to_string(),
))
.unwrap();
let resp_alice = app.clone().oneshot(peer_alice_req).await.unwrap();
assert_eq!(resp_alice.status(), StatusCode::OK);
let peer_bob_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-bob",
"allowed_ips": "10.100.0.3/32"
})
.to_string(),
))
.unwrap();
let resp_bob = app.clone().oneshot(peer_bob_req).await.unwrap();
assert_eq!(resp_bob.status(), StatusCode::OK);
// 5. Create 1 peer under wg1
let peer_charlie_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-charlie",
"allowed_ips": "10.200.0.2/32"
})
.to_string(),
))
.unwrap();
let resp_charlie = app.clone().oneshot(peer_charlie_req).await.unwrap();
assert_eq!(resp_charlie.status(), StatusCode::OK);
// 6. Test GET /api/v1/peers (All peers across all interfaces)
let list_all_req = Request::builder()
.uri("/api/v1/peers")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let list_all_resp = app.clone().oneshot(list_all_req).await.unwrap();
assert_eq!(list_all_resp.status(), StatusCode::OK);
let all_peers_bytes = to_bytes(list_all_resp.into_body(), usize::MAX)
.await
.unwrap();
let all_peers: Vec<Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
assert_eq!(
all_peers.len(),
3,
"GET /api/v1/peers must return all 3 peers across both interfaces"
);
let peer_names: Vec<&str> = all_peers
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(peer_names.contains(&"peer-alice"));
assert!(peer_names.contains(&"peer-bob"));
assert!(peer_names.contains(&"peer-charlie"));
// Verify representative fields are present and valid
for p in &all_peers {
assert!(p["id"].is_string());
assert!(p["public_key"].is_string());
assert!(p["interface_id"].is_string());
assert!(p["state"].is_string());
assert!(p["allowed_ips"].is_string());
}
// 7. Verify interface-scoped endpoint still works and returns only that interface's peers
let list_iface0_req = Request::builder()
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let iface0_peers_resp = app.clone().oneshot(list_iface0_req).await.unwrap();
assert_eq!(iface0_peers_resp.status(), StatusCode::OK);
let iface0_peers: Vec<Value> = serde_json::from_slice(
&to_bytes(iface0_peers_resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(iface0_peers.len(), 2, "wg0 must return exactly 2 peers");
let list_iface1_req = Request::builder()
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let iface1_peers_resp = app.oneshot(list_iface1_req).await.unwrap();
assert_eq!(iface1_peers_resp.status(), StatusCode::OK);
let iface1_peers: Vec<Value> = serde_json::from_slice(
&to_bytes(iface1_peers_resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
}
@@ -0,0 +1,567 @@
//! Comprehensive tests for WireGuard road-warrior data-plane, cryptokey routing,
//! endpoint resolution, telemetry ingestion, and reconciliation invariants.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use chrono::Utc;
use ipnet::IpNet;
use nx9_wg_api::reconciliation::ReconciliationEngine;
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_core::crypto::generate_keypair;
use nx9_wg_core::types::wireguard::{Interface, Peer, PeerProfile, PeerState, PeerType};
use nx9_wg_db::Store;
use nx9_wg_network::{NetworkEngine, SimulatedNetworkEngine};
use nx9_wireguard::{
ClientConfigBuilder, LiveInterfaceStats, LivePeerStats, SimulatedWireGuardEngine,
WireGuardEngine,
};
use std::str::FromStr;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
async fn setup_test_context() -> (AppState, Interface, Peer, String) {
let store = Store::connect_in_memory().await.unwrap();
store.migrate().await.unwrap();
let now = Utc::now().naive_utc();
let hash = nx9_wg_core::crypto::hash_password("testadminpass123").unwrap();
store.create_admin("admin", &hash).await.unwrap();
let session = nx9_wg_core::types::auth::Session {
id: "test-dataplane-session-id".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.100.0.1/24").unwrap(),
address_v6: None,
mtu: Some(1420),
dns: Some("1.1.1.1, 1.0.0.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: "Mobile".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: "0.0.0.0/0, ::/0".to_string(),
server_allowed_ips: None,
address_v4: Some(IpNet::from_str("10.100.0.9/32").unwrap()),
address_v6: None,
dns: Some("1.1.1.1, 1.0.0.1".to_string()),
mtu: Some(1420),
persistent_keepalive: Some(25),
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);
(state, interface, peer, session.id)
}
#[tokio::test]
async fn test_road_warrior_server_allowed_ips_vs_client_full_tunnel() {
let (_state, iface, peer, _session_id) = setup_test_context().await;
// 1. Server-side WireGuard peer AllowedIPs MUST be strictly the assigned client IP (10.100.0.9/32)
assert_eq!(peer.server_wireguard_allowed_ips(), "10.100.0.9/32");
// 2. Client configuration MUST contain the FullTunnel routing policy (0.0.0.0/0 for IPv4-only server)
let conf = ClientConfigBuilder::build(&peer, &iface, "192.168.1.8:51820").unwrap();
assert!(conf.contains("Address = 10.100.0.9/32"));
assert!(conf.contains("AllowedIPs = 0.0.0.0/0"));
assert!(conf.contains("Endpoint = 192.168.1.8:51820"));
assert!(conf.contains("PersistentKeepalive = 25"));
// Dual-stack interface exports dual-stack full tunnel
let mut dual_iface = iface.clone();
dual_iface.address_v6 = Some(IpNet::from_str("fd00::1/64").unwrap());
let dual_conf = ClientConfigBuilder::build(&peer, &dual_iface, "192.168.1.8:51820").unwrap();
assert!(dual_conf.contains("AllowedIPs = 0.0.0.0/0, ::/0"));
}
#[tokio::test]
async fn test_endpoint_resolution_failure_and_override() {
let (state, _iface, peer, session_id) = setup_test_context().await;
let app = build_api_router(state.clone());
// 1. Config export without persistent setting or query endpoint fails with 422
let req = Request::builder()
.uri(format!("/api/v1/peers/{}/config", 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::UNPROCESSABLE_ENTITY);
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap();
let err_json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
err_json["error"]["message"]
.as_str()
.unwrap()
.contains("No reachable WireGuard server endpoint is configured")
);
// 2. Setting persistent server_endpoint setting succeeds
state
.store
.set_setting("server_endpoint", "192.168.1.8:51820", false)
.await
.unwrap();
let req = Request::builder()
.uri(format!("/api/v1/peers/{}/config", 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("Endpoint = 192.168.1.8:51820"));
// 3. Explicit query parameter overrides persistent setting
let req = Request::builder()
.uri(format!(
"/api/v1/peers/{}/config?server_endpoint=vpn.publicdomain.org:51820",
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("Endpoint = vpn.publicdomain.org:51820"));
}
#[tokio::test]
async fn test_learned_endpoint_and_handshake_telemetry_ingestion() {
let (state, iface, peer, _session_id) = setup_test_context().await;
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
// Sync initial state to simulated engine
wg_engine
.sync_interface(&iface, std::slice::from_ref(&peer))
.await
.unwrap();
let reconciler =
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
// Initially peer has no learned endpoint or handshake in DB
let p_db = state.store.get_peer(peer.id).await.unwrap().unwrap();
assert!(p_db.endpoint.is_none());
assert!(p_db.last_handshake_at.is_none());
// Simulate incoming authenticated handshake from client
let hs_time = Utc::now().naive_utc();
let learned_client_ep = "192.168.1.50:41234".to_string();
// Apply initial baseline state to ensure all subsystems start converged
let initial_report = reconciler.apply().await.unwrap();
assert!(initial_report.success);
// Directly simulate kernel stats containing learned endpoint and handshake
let live_peers = vec![LivePeerStats {
public_key: peer.public_key.as_str().to_string(),
endpoint: Some(learned_client_ep.clone()),
rx_bytes: 1024,
tx_bytes: 2048,
last_handshake_at: Some(hs_time),
allowed_ips: vec!["10.100.0.9/32".to_string()],
persistent_keepalive: Some(25),
}];
wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.as_str().to_string(),
listen_port: iface.listen_port,
fwmark: 0,
peers: live_peers,
addresses: vec!["10.100.0.1/24".to_string()],
mtu: Some(1420),
is_up: true,
})
.await;
// Run reconciliation plan — should ingest telemetry without peer drift
let plan = reconciler.plan().await.unwrap();
assert_eq!(plan.peer_changes, 0);
// Verify learned telemetry was ingested into the SQLite store
let updated_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
assert_eq!(updated_peer.endpoint.as_deref(), Some("192.168.1.50:41234"));
assert_eq!(
updated_peer
.last_handshake_at
.unwrap()
.and_utc()
.timestamp(),
hs_time.and_utc().timestamp()
);
}
#[tokio::test]
async fn test_peer_allowed_ips_and_keepalive_kernel_drift() {
let (state, iface, peer, _session_id) = setup_test_context().await;
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler =
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
// Inject drift: kernel peer erroneously has 0.0.0.0/0 as AllowedIPs and keepalive = 10s
let drifted_peers = vec![LivePeerStats {
public_key: peer.public_key.as_str().to_string(),
endpoint: None,
rx_bytes: 0,
tx_bytes: 0,
last_handshake_at: None,
allowed_ips: vec!["0.0.0.0/0".to_string(), "::/0".to_string()],
persistent_keepalive: Some(10),
}];
wg_engine
.inject_interface_stats(LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.as_str().to_string(),
listen_port: iface.listen_port,
fwmark: 0,
peers: drifted_peers,
addresses: vec!["10.100.0.1/24".to_string()],
mtu: Some(1420),
is_up: true,
})
.await;
// Reconciliation plan MUST detect this semantic drift
let plan = reconciler.plan().await.unwrap();
assert!(plan.has_drift);
assert_eq!(plan.peer_changes, 1);
assert!(
plan.actions
.iter()
.any(|a| a.action_type == "update_peer" && a.description.contains("AllowedIPs drift"))
);
// Execute reconciliation apply
let report = reconciler.apply().await.unwrap();
assert!(report.success);
assert_eq!(
report.status,
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
);
// Verify post-apply convergence: zero drift
let post_plan = reconciler.plan().await.unwrap();
assert!(!post_plan.has_drift);
assert_eq!(post_plan.peer_changes, 0);
// Verify live kernel stats now match desired server AllowedIPs (10.100.0.9/32)
let live_stats = wg_engine.get_interface_stats("wg0").await.unwrap().unwrap();
assert_eq!(live_stats.peers[0].allowed_ips, vec!["10.100.0.9/32"]);
assert_eq!(live_stats.peers[0].persistent_keepalive, Some(25));
}
#[tokio::test]
async fn test_forwarding_and_nat_reconciliation_invariants() {
let (state, _iface, _peer, _session_id) = setup_test_context().await;
let wg_engine = Arc::new(SimulatedWireGuardEngine::new());
let net_engine = Arc::new(SimulatedNetworkEngine::new());
let reconciler =
ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone());
// Enable NAT masquerade in settings
state
.store
.set_setting("nat_enabled", "true", false)
.await
.unwrap();
// Reconcile apply
let report = reconciler.apply().await.unwrap();
assert!(report.success);
assert_eq!(
report.status,
nx9_wg_api::reconciliation::ReconciliationStatus::Converged
);
// Verify NAT masquerade is active in network engine for 10.100.0.0/24 subnet
let ruleset = net_engine.get_active_nftables_ruleset().await.unwrap();
assert!(ruleset.contains("masquerade"));
assert!(ruleset.contains("10.100.0.0/24"));
// Re-planning shows 0 drift
let plan = reconciler.plan().await.unwrap();
assert!(!plan.has_drift);
assert_eq!(plan.firewall_changes, 0);
assert_eq!(plan.route_changes, 0);
assert_eq!(plan.interface_changes, 0);
}
#[tokio::test]
async fn test_interface_editing_persistence_and_key_preservation() {
let (state, iface, _peer, session_id) = setup_test_context().await;
let app = build_api_router(state.clone());
let orig_priv_key = iface.private_key.clone();
let orig_pub_key = iface.public_key.clone();
let orig_id = iface.id;
// 1. Edit interface wg0 (change address_v4, listen_port, MTU, DNS, enabled)
let update_req = Request::builder()
.method("PUT")
.uri(format!("/api/v1/interfaces/{}", iface.id))
.header("Content-Type", "application/json")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::from(
serde_json::json!({
"name": "wg0",
"address_v4": "10.200.0.1/24",
"listen_port": 51822,
"mtu": 1360,
"dns": "9.9.9.9",
"enabled": true
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(update_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 2. Query updated interface from database
let updated_iface = state.store.get_interface(orig_id).await.unwrap().unwrap();
assert_eq!(updated_iface.address_v4.to_string(), "10.200.0.1/24");
assert_eq!(updated_iface.listen_port, 51822);
assert_eq!(updated_iface.mtu, Some(1360));
assert_eq!(updated_iface.dns, Some("9.9.9.9".to_string()));
// 3. Verify private key, public key, and ID were strictly preserved (NEVER regenerated)
assert_eq!(updated_iface.id, orig_id);
assert_eq!(updated_iface.private_key.as_str(), orig_priv_key.as_str());
assert_eq!(updated_iface.public_key.as_str(), orig_pub_key.as_str());
// 4. Verify peers attached to wg0 were preserved
let peers = state.store.list_peers_for_interface(orig_id).await.unwrap();
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].name, "Mobile");
}
#[tokio::test]
async fn test_server_endpoint_persistence_validation_and_export_precedence() {
let (state, _iface, peer, session_id) = setup_test_context().await;
let app = build_api_router(state.clone());
// 1. Invalid server_endpoint format (missing port) is rejected with 422
let invalid_setting_req = Request::builder()
.method("PUT")
.uri("/api/v1/system/settings")
.header("Content-Type", "application/json")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::from(
serde_json::json!({
"key": "server_endpoint",
"value": "192.168.1.8", // missing port!
"is_secret": false
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(invalid_setting_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
// 2. Valid server_endpoint saves successfully
let valid_setting_req = Request::builder()
.method("PUT")
.uri("/api/v1/system/settings")
.header("Content-Type", "application/json")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::from(
serde_json::json!({
"key": "server_endpoint",
"value": "192.168.1.8:51820",
"is_secret": false
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(valid_setting_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 3. Export config without override consumes the persisted setting automatically
let export_req = Request::builder()
.uri(format!("/api/v1/peers/{}/config", peer.id))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(export_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
assert!(conf_str.contains("Endpoint = 192.168.1.8:51820"));
// 4. Export QR code returns JSON with SVG and data_url containing the same endpoint
let qr_req = Request::builder()
.uri(format!("/api/v1/peers/{}/qr", peer.id))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(qr_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let qr_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let qr_json: serde_json::Value = serde_json::from_slice(&qr_bytes).unwrap();
assert!(qr_json["svg"].as_str().unwrap().contains("<svg"));
assert!(
qr_json["data_url"]
.as_str()
.unwrap()
.starts_with("data:image/png;base64,")
);
// 5. Explicit override query parameter takes precedence over setting
let override_req = Request::builder()
.uri(format!(
"/api/v1/peers/{}/config?endpoint=vpn.wan-domain.org:51820",
peer.id
))
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(override_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let conf_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let conf_str = String::from_utf8(conf_bytes.to_vec()).unwrap();
assert!(conf_str.contains("Endpoint = vpn.wan-domain.org:51820"));
}
#[tokio::test]
async fn test_peer_telemetry_enrichment_and_status_transitions() {
let (state_orig, iface, peer, session_id) = setup_test_context().await;
let simulated_wg = Arc::new(SimulatedWireGuardEngine::new());
let simulated_net = Arc::new(SimulatedNetworkEngine::new());
let state = AppState::with_engines(
state_orig.store.clone(),
simulated_wg.clone(),
simulated_net.clone(),
);
// Inject live kernel statistics into simulated WireGuard engine
let recent_hs = Utc::now().naive_utc() - chrono::Duration::seconds(15);
let live_peer = LivePeerStats {
public_key: peer.public_key.to_string(),
endpoint: Some("192.168.1.50:41234".to_string()),
rx_bytes: 409600,
tx_bytes: 819200,
last_handshake_at: Some(recent_hs),
allowed_ips: vec!["10.100.0.9/32".to_string()],
persistent_keepalive: Some(25),
};
let live_iface = LiveInterfaceStats {
name: iface.name.clone(),
public_key: iface.public_key.to_string(),
listen_port: iface.listen_port,
fwmark: 0,
peers: vec![live_peer],
addresses: vec!["10.100.0.1/24".to_string()],
mtu: Some(1420),
is_up: true,
};
// Inject live stats into simulated_wg
simulated_wg.inject_interface_stats(live_iface).await;
let app = build_api_router(state.clone());
// Query GET /api/v1/peers
let list_req = Request::builder()
.uri("/api/v1/peers")
.header("Cookie", format!("nx9_session={session_id}"))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body_bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let peers_json: Vec<serde_json::Value> = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(peers_json.len(), 1);
let p = &peers_json[0];
assert_eq!(p["name"], "Mobile");
assert_eq!(p["endpoint"], "192.168.1.50:41234");
assert_eq!(p["rx_bytes"], 409600);
assert_eq!(p["tx_bytes"], 819200);
// Handshake is serialized as explicit RFC3339 UTC string with offset/Z
let hs_str = p["last_handshake_at"].as_str().unwrap();
assert!(hs_str.contains('T'));
assert!(hs_str.ends_with('Z') || hs_str.contains("+00:00"));
// Verify learned telemetry was cached in SQLite
let db_peer = state.store.get_peer(peer.id).await.unwrap().unwrap();
assert_eq!(db_peer.endpoint, Some("192.168.1.50:41234".to_string()));
assert_eq!(
db_peer.last_handshake_at.unwrap().and_utc().timestamp(),
recent_hs.and_utc().timestamp()
);
}
@@ -7,6 +7,24 @@ use nx9_wg_api::state::AppState;
use nx9_wg_db::Store;
use tower::ServiceExt;
async fn setup_test_app() -> (axum::Router, Store) {
let store = Store::connect_in_memory().await.expect("connect store");
store.migrate().await.expect("migrate store");
let config = nx9_wg_core::config::AppConfig::default();
let opts = nx9_wg_api::auth::BootstrapOptions {
cli_password: Some("TestAdminPassword123!".to_string()),
..Default::default()
};
nx9_wg_api::auth::bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap admin");
let state = AppState::new(store.clone());
let app = build_api_router(state);
(app, store)
}
#[tokio::test]
async fn test_ui_spa_index_and_stylesheet_endpoints() {
let store = Store::connect_in_memory().await.expect("connect store");
@@ -121,6 +139,11 @@ async fn test_ui_api_complete_functional_loop() {
.await
.expect("bootstrap admin");
store
.set_setting("server_endpoint", "vpn.example.com", false)
.await
.expect("set server_endpoint");
let state = AppState::new(store.clone());
let app = build_api_router(state);
@@ -227,6 +250,26 @@ async fn test_ui_api_complete_functional_loop() {
let peer_json: serde_json::Value = serde_json::from_slice(&peer_body).unwrap();
let peer_id = peer_json["id"].as_str().unwrap();
// 4b. UI fetches collection of all peers (Peers page render: GET /api/v1/peers)
let res_all_peers = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/peers")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("get all peers");
assert_eq!(res_all_peers.status(), StatusCode::OK);
let all_peers_bytes = to_bytes(res_all_peers.into_body(), 1024 * 1024)
.await
.unwrap();
let all_peers_json: Vec<serde_json::Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
assert_eq!(all_peers_json.len(), 1);
assert_eq!(all_peers_json[0]["name"], "alice-phone");
// 5. UI downloads Client Config & SVG QR Code
let res_conf = app
.clone()
@@ -452,6 +495,7 @@ async fn test_ui_api_complete_functional_loop() {
// 13. UI Logout
let res_logout = app
.clone()
.oneshot(
Request::builder()
.method("POST")
@@ -463,4 +507,205 @@ async fn test_ui_api_complete_functional_loop() {
.await
.expect("logout request");
assert_eq!(res_logout.status(), StatusCode::OK);
// 14. Post-Logout: Session must be completely rejected on protected endpoints
let res_post_logout = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/auth/session")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("post-logout session request");
assert_eq!(res_post_logout.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_logout_session_invalidation_and_idempotency() {
let (app, _store) = setup_test_app().await;
// 1. Initial login
let login_body = serde_json::to_vec(&serde_json::json!({
"username": "admin",
"password": "TestAdminPassword123!"
}))
.unwrap();
let res_login = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(login_body))
.unwrap(),
)
.await
.expect("login request");
assert_eq!(res_login.status(), StatusCode::OK);
let cookie_header = res_login
.headers()
.get(axum::http::header::SET_COOKIE)
.expect("Set-Cookie header present")
.to_str()
.unwrap();
let session_cookie = cookie_header
.split(';')
.next()
.expect("nx9_session cookie")
.to_string();
// 2. Verified access before logout
let res_auth_session = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/auth/session")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res_auth_session.status(), StatusCode::OK);
let res_system = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/system")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res_system.status(), StatusCode::OK);
// 3. Perform Logout
let res_logout = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/logout")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res_logout.status(), StatusCode::OK);
let logout_cookie = res_logout
.headers()
.get(axum::http::header::SET_COOKIE)
.expect("Set-Cookie on logout")
.to_str()
.unwrap();
assert!(
logout_cookie.contains("Max-Age=0"),
"Logout must clear session cookie with Max-Age=0"
);
// 4. All protected endpoints must return 401 Unauthorized after logout
let endpoints = [
"/api/v1/auth/session",
"/api/v1/system",
"/api/v1/interfaces",
"/api/v1/networks",
"/api/v1/routes",
"/api/v1/firewall/rules",
"/api/v1/diagnostics/all",
"/api/v1/client-profiles",
"/api/v1/audit",
"/api/v1/backups",
"/api/v1/reconcile/plan",
];
for ep in endpoints {
let res_blocked = app
.clone()
.oneshot(
Request::builder()
.uri(ep)
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res_blocked.status(),
StatusCode::UNAUTHORIZED,
"Endpoint {ep} must be blocked (401) after logout"
);
}
// 5. Repeated logout when already logged out is safe and idempotent
let res_logout_again = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/logout")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res_logout_again.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_ui_index_contains_login_view_and_hidden_app_layout() {
let (app, _store) = setup_test_app().await;
let res = app
.clone()
.oneshot(
Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("index request");
assert_eq!(res.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(res.into_body(), 1024 * 1024)
.await
.unwrap();
let html = String::from_utf8(bytes.to_vec()).unwrap();
assert!(
html.contains("id=\"login-view\""),
"HTML must contain dedicated login-view container"
);
assert!(
html.contains("id=\"app-layout\" style=\"display: none;\""),
"app-layout must be initially hidden until authenticated"
);
assert!(
html.contains("id=\"login-username\""),
"HTML must contain login username input"
);
assert!(
html.contains("id=\"login-password\""),
"HTML must contain login password input"
);
assert!(
html.contains("id=\"login-submit-btn\""),
"HTML must contain login submit button"
);
assert!(
html.contains("handleLogout()"),
"HTML must contain handleLogout handler"
);
}