//! Comprehensive Integration test suite for Interface Lifecycle Hardening: //! - Interface deletion converges desired state and kernel state //! - wg0 protection (deletion and disabling rejected via API & CLI) //! - Reconciliation orphan detection and cleanup //! - Desired-state read failure safety guard //! - Interface restart lifecycle //! - SPA Read-Only CLI Console allowlist and safety use axum::body::{Body, to_bytes}; use axum::http::{Request, StatusCode, header}; use chrono::Utc; use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin}; use nx9_wg_api::reconciliation::ReconciliationEngine; use nx9_wg_api::routes::build_api_router; use nx9_wg_api::routes::cli::{ExecuteCliRequest, build_safe_argv, scrub_secrets}; use nx9_wg_api::state::AppState; use nx9_wg_core::config::AppConfig; use nx9_wg_core::crypto::generate_keypair; use nx9_wg_core::types::wireguard::{ Interface, InterfaceRole, Peer, PeerProfile, PeerState, PeerType, }; use nx9_wg_core::validation::validate_cidr; use nx9_wg_db::Store; use nx9_wg_network::SimulatedNetworkEngine; use nx9_wireguard::{LiveInterfaceStats, SimulatedWireGuardEngine, WireGuardEngine}; use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::Arc; use tempfile::{TempDir, tempdir}; use tower::ServiceExt; use uuid::Uuid; async fn setup_test_context() -> ( TempDir, Store, AppState, Arc, Arc, ReconciliationEngine, axum::Router, String, ) { let dir = tempdir().expect("create temp dir"); let db_path = dir.path().join("lifecycle_test.db"); let store = Store::connect(&db_path.to_string_lossy()) .await .expect("connect to db"); store.migrate().await.expect("run migrations"); let config = AppConfig::default(); let opts = BootstrapOptions { cli_password: Some("AdminSecret123!".to_string()), ..Default::default() }; bootstrap_admin(&store, &config, &opts) .await .expect("bootstrap"); let wg_engine = Arc::new(SimulatedWireGuardEngine::new()); let net_engine = Arc::new(SimulatedNetworkEngine::new()); let state = AppState::with_engines(store.clone(), wg_engine.clone(), net_engine.clone()); let reconciler = ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone()); 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(); ( dir, store, state, wg_engine, net_engine, reconciler, app, session_cookie, ) } fn fixture_interface(name: &str, v4_cidr: &str) -> Interface { let (priv_k, pub_k) = generate_keypair(); let now = Utc::now().naive_utc(); Interface { id: Uuid::new_v4(), name: name.to_string(), role: InterfaceRole::Overlay, private_key: priv_k, public_key: pub_k, listen_port: Some(51820), address_v4: validate_cidr(v4_cidr).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, } } fn fixture_peer(iface_id: Uuid, name: &str, v4_addr: &str) -> Peer { let (priv_k, pub_k) = generate_keypair(); let now = Utc::now().naive_utc(); Peer { id: Uuid::new_v4(), interface_id: iface_id, name: name.to_string(), public_key: pub_k, preshared_key: None, private_key: Some(priv_k), endpoint: None, address_v4: Some(validate_cidr(v4_addr).unwrap()), address_v6: None, allowed_ips: "0.0.0.0/0".to_string(), server_allowed_ips: None, dns: Some("1.1.1.1".to_string()), persistent_keepalive: Some(25), mtu: Some(1420), state: PeerState::Active, peer_type: PeerType::RoadWarrior, profile: PeerProfile::FullTunnel, last_handshake_at: None, expires_at: None, created_at: now, updated_at: now, } } #[tokio::test] async fn test_interface_delete_removes_kernel_state() { let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await; // 1. Create desired interface let iface = fixture_interface("custom0", "10.200.0.1/24"); store .create_interface(&iface) .await .expect("create interface"); // 2. Sync to simulated kernel wg_engine.sync_interface(&iface, &[]).await.expect("sync"); // 3. Verify kernel interface exists let live = wg_engine.list_interfaces().await.unwrap(); assert!(live.contains(&"custom0".to_string())); // 4. Delete via API let 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(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); // 5. Verify DB object removed let db_iface = store.get_interface(iface.id).await.unwrap(); assert!(db_iface.is_none()); // 6. Verify kernel interface removed let live_after = wg_engine.list_interfaces().await.unwrap(); assert!(!live_after.contains(&"custom0".to_string())); } #[tokio::test] async fn test_wg0_deletion_rejected() { let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await; // 1. Create wg0 interface let wg0 = fixture_interface("wg0", "10.100.0.1/24"); store.create_interface(&wg0).await.expect("create wg0"); wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0"); // 2. Attempt deletion via API let req = Request::builder() .method("DELETE") .uri(format!("/api/v1/interfaces/{}", wg0.id)) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let val: Value = serde_json::from_slice(&body).unwrap(); assert!(val["error"]["message"].as_str().unwrap().contains("wg0")); // 3. Confirm DB and kernel state remain intact let db_wg0 = store.get_interface(wg0.id).await.unwrap(); assert!(db_wg0.is_some()); let live = wg_engine.list_interfaces().await.unwrap(); assert!(live.contains(&"wg0".to_string())); } #[tokio::test] async fn test_wg0_disable_rejected() { let (_dir, store, _state, _wg_engine, _net, _rec, app, cookie) = setup_test_context().await; // 1. Create wg0 interface let wg0 = fixture_interface("wg0", "10.100.0.1/24"); store.create_interface(&wg0).await.expect("create wg0"); // 2. Attempt disable via API let req = Request::builder() .method("POST") .uri(format!("/api/v1/interfaces/{}/disable", wg0.id)) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); // 3. Confirm enabled remains true in DB let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap(); assert!(db_wg0.enabled); } #[tokio::test] async fn test_orphan_interface_reconciliation() { let (_dir, store, _state, wg_engine, _net, reconciler, _app, _cookie) = setup_test_context().await; // 1. Create desired interface wg0 let wg0 = fixture_interface("wg0", "10.100.0.1/24"); store.create_interface(&wg0).await.expect("create wg0"); wg_engine.sync_interface(&wg0, &[]).await.expect("sync wg0"); // 2. Inject orphan kernel-only interface (e.g. proton0) wg_engine .inject_interface_stats(LiveInterfaceStats { name: "proton0".to_string(), public_key: "OrphanPubKey123456789012345678901234567890=".to_string(), listen_port: 51821, fwmark: 0, addresses: vec!["10.2.0.2/32".to_string()], mtu: Some(1420), is_up: true, peers: vec![], }) .await; // 3. Verify kernel has both wg0 and proton0 let live = wg_engine.list_interfaces().await.unwrap(); assert!(live.contains(&"wg0".to_string())); assert!(live.contains(&"proton0".to_string())); // 4. Run reconciliation plan let plan = reconciler.plan().await.expect("plan"); assert!(plan.has_drift); let orphan_action = plan .actions .iter() .find(|a| a.action_type == "delete_orphan_interface" && a.resource_id == "proton0"); assert!( orphan_action.is_some(), "Expected orphan removal action for proton0" ); // 5. Run reconciliation apply let report = reconciler.apply().await.expect("apply"); assert!(report.success); // 6. Confirm kernel interface proton0 is removed, wg0 remains let live_after = wg_engine.list_interfaces().await.unwrap(); assert!(live_after.contains(&"wg0".to_string())); assert!(!live_after.contains(&"proton0".to_string())); } #[tokio::test] async fn test_interface_restart_preserves_state() { let (_dir, store, _state, wg_engine, _net, _rec, app, cookie) = setup_test_context().await; // 1. Create interface with peer let wg0 = fixture_interface("wg0", "10.100.0.1/24"); store.create_interface(&wg0).await.expect("create wg0"); let peer = fixture_peer(wg0.id, "mobile-alice", "10.100.0.5/32"); store.create_peer(&peer).await.expect("create peer"); // 2. Initial sync wg_engine .sync_interface(&wg0, &[peer.clone()]) .await .expect("sync"); // 3. Call restart API let req = Request::builder() .method("POST") .uri(format!("/api/v1/interfaces/{}/restart", wg0.id)) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); // 4. Verify DB object remains identical let db_wg0 = store.get_interface(wg0.id).await.unwrap().unwrap(); assert_eq!(db_wg0.id, wg0.id); assert_eq!(db_wg0.name, "wg0"); assert_eq!(db_wg0.address_v4, wg0.address_v4); assert_eq!(db_wg0.public_key.as_str(), wg0.public_key.as_str()); // 5. Verify live kernel state converged with peer restored let stats = wg_engine .get_interface_stats("wg0") .await .unwrap() .expect("wg0 stats"); assert_eq!(stats.name, "wg0"); assert_eq!(stats.peers.len(), 1); assert_eq!(stats.peers[0].public_key, peer.public_key.as_str()); } #[tokio::test] async fn test_reconcile_does_not_delete_on_desired_state_read_failure() { let (_dir, _store, state, wg_engine, net_engine, _rec, _app, _cookie) = setup_test_context().await; // 1. Inject live interface in kernel wg_engine .inject_interface_stats(LiveInterfaceStats { name: "wg0".to_string(), public_key: "Wg0PubKey12345678901234567890123456789012=".to_string(), listen_port: 51820, fwmark: 0, addresses: vec!["10.100.0.1/24".to_string()], mtu: Some(1420), is_up: true, peers: vec![], }) .await; // 2. Desired state is empty in DB // Reconciler should abort rather than mass-deleting live interfaces let reconciler = ReconciliationEngine::new(state.clone(), wg_engine.clone(), net_engine.clone()); let result = reconciler.apply().await; assert!(result.is_err(), "Expected reconciliation to abort safely"); // 3. Confirm live interface was NOT deleted let live = wg_engine.list_interfaces().await.unwrap(); assert!(live.contains(&"wg0".to_string())); } #[tokio::test] async fn test_cli_console_readonly_whitelist() { // 1. Test allowed read-only commands let allowed_tests = vec![ ExecuteCliRequest { command: "version".to_string(), subcommand: None, sub_subcommand: None, target: None, parameters: HashMap::new(), }, ExecuteCliRequest { command: "system".to_string(), subcommand: Some("status".to_string()), sub_subcommand: None, target: None, parameters: HashMap::new(), }, ExecuteCliRequest { command: "interface".to_string(), subcommand: Some("list".to_string()), sub_subcommand: None, target: None, parameters: HashMap::new(), }, ExecuteCliRequest { command: "interface".to_string(), subcommand: Some("show".to_string()), sub_subcommand: None, target: Some("wg0".to_string()), parameters: HashMap::new(), }, ExecuteCliRequest { command: "peer".to_string(), subcommand: Some("list".to_string()), sub_subcommand: None, target: None, parameters: HashMap::new(), }, ExecuteCliRequest { command: "live".to_string(), subcommand: Some("interface".to_string()), sub_subcommand: Some("list".to_string()), target: None, parameters: HashMap::new(), }, ExecuteCliRequest { command: "reconcile".to_string(), subcommand: Some("status".to_string()), sub_subcommand: None, target: None, parameters: HashMap::new(), }, ]; for req in allowed_tests { let argv = build_safe_argv(&req); assert!( argv.is_ok(), "Expected command {:?} to be allowed", req.command ); } // 2. Test mutating commands are rejected let mutating_tests = vec![ "create", "delete", "update", "set", "enable", "disable", "restart", "apply", "restore", "reset", "remove", "flush", "add", "sh", "bash", "sudo", ]; for cmd in mutating_tests { let req = ExecuteCliRequest { command: cmd.to_string(), subcommand: None, sub_subcommand: None, target: None, parameters: HashMap::new(), }; let argv = build_safe_argv(&req); assert!( argv.is_err(), "Expected mutating command '{cmd}' to be rejected" ); } // 3. Test shell meta characters in target are rejected let bad_targets = vec![ "-option", "wg0; rm -rf /", "wg0 | ls", "wg0 & sleep 5", "wg0 `whoami`", "wg0 $(whoami)", ]; for bad in bad_targets { let req = ExecuteCliRequest { command: "interface".to_string(), subcommand: Some("show".to_string()), sub_subcommand: None, target: Some(bad.to_string()), parameters: HashMap::new(), }; let argv = build_safe_argv(&req); assert!( argv.is_err(), "Expected unsafe target '{bad}' to be rejected" ); } // 4. Test secrets scrubbing let raw_text = r#" Interface: wg0 PrivateKey: aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg== PublicKey: dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA== PresharedKey: c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE= Addresses: 10.100.0.1/24 "#; let scrubbed = scrub_secrets(raw_text); assert!(!scrubbed.contains("aGVsbG8td29ybGQtdGhpcy1pcy1hLXByaXZhdGUta2V5Cg==")); assert!(!scrubbed.contains("c2VjcmV0LXByZXNoYXJlZC1rZXktMTIzNDU2Nzg5MDE=")); assert!(scrubbed.contains("[REDACTED]")); assert!(scrubbed.contains("10.100.0.1/24")); assert!(scrubbed.contains("dGVzdC1wdWJsaWMta2V5LTEyMzQ1Njc4OTAxMjM0NTY3OA==")); }