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 nx9_wg_network::SimulatedNetworkEngine; use nx9_wireguard::SimulatedWireGuardEngine; use serde_json::{Value, json}; use std::sync::Arc; 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::with_engines( store, Arc::new(SimulatedWireGuardEngine::new()), Arc::new(SimulatedNetworkEngine::new()), ); 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"); assert_eq!(val["version"], "1.1.0"); } #[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 wg0 interface (must be rejected with 403 Forbidden) let del_wg0_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_wg0_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); // 7. Restart wg0 interface (must succeed) let restart_wg0_req = Request::builder() .method("POST") .uri(format!("/api/v1/interfaces/{iface_id}/restart")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(restart_wg0_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); // 8. Create secondary interface and delete it (must succeed) let create_sec_req = Request::builder() .method("POST") .uri("/api/v1/interfaces") .header(header::COOKIE, &cookie) .header(header::CONTENT_TYPE, "application/json") .body(Body::from( json!({ "name": "custom0", "listen_port": 51822, "address_v4": "10.200.0.1/24" }) .to_string(), )) .unwrap(); let resp = app.clone().oneshot(create_sec_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let sec_val: Value = serde_json::from_slice(&body).unwrap(); let sec_id = sec_val["id"].as_str().unwrap(); let del_sec_req = Request::builder() .method("DELETE") .uri(format!("/api/v1/interfaces/{sec_id}")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(del_sec_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); } #[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 = 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 = 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 = 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"); } #[tokio::test] async fn test_peer_creation_allocates_from_selected_network() { let (app, cookie) = setup_test_app().await; // Interface Network (WireGuard transport address space) 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.100.0.1/24" }) .to_string(), )) .unwrap(); let resp = app.clone().oneshot(create_iface_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let iface_val: Value = serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap(); let iface_id = iface_val["id"].as_str().unwrap().to_string(); assert_eq!(iface_val["address_v4"], "10.100.0.1/24"); // Subnet Network (peer allocation domain) let create_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": "mobile-clients", "cidr": "10.100.2.0/24" }) .to_string(), )) .unwrap(); let resp = app.clone().oneshot(create_net_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let net_val: Value = serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap(); let network_id = net_val["id"].as_str().unwrap(); assert_eq!(net_val["cidr"], "10.100.2.0/24"); // Exact production enrollment payload: selected Subnet Network UUID as network_id. let selected_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": "sunil-moto-mobile-network-01", "peer_type": "road_warrior", "profile": "full_tunnel", "mtu": 1280, "persistent_keepalive": 25, "dns": "1.1.1.1, 1.0.0.1", "allowed_ips": "0.0.0.0/0, ::/0", "network_id": network_id }) .to_string(), )) .unwrap(); let resp = app.clone().oneshot(selected_peer_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let selected_peer: Value = serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap(); let selected_addr = selected_peer["address_v4"].as_str().unwrap(); assert_eq!( selected_addr, "10.100.2.1/32", "selected Network must allocate the first host of 10.100.2.0/24, got {selected_addr}" ); assert!( selected_addr.starts_with("10.100.2."), "selected Network must allocate from 10.100.2.0/24, got {selected_addr}" ); assert!( !selected_addr.starts_with("10.100.0."), "must not allocate from Interface Network 10.100.0.0/24 when a Subnet Network is selected, got {selected_addr}" ); assert!(selected_addr.ends_with("/32")); // network_id = null preserves existing fallback (Interface Network CIDR) let fallback_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": "bob-fallback", "peer_type": "road_warrior", "profile": "full_tunnel", "network_id": null }) .to_string(), )) .unwrap(); let resp = app.clone().oneshot(fallback_peer_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let fallback_peer: Value = serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap(); let fallback_addr = fallback_peer["address_v4"].as_str().unwrap(); assert!( fallback_addr.starts_with("10.100.0."), "network_id=null must preserve fallback allocation from Interface Network 10.100.0.0/24, got {fallback_addr}" ); assert!( !fallback_addr.starts_with("10.100.2."), "network_id=null must not allocate from a Subnet Network, got {fallback_addr}" ); assert!(fallback_addr.ends_with("/32")); // WireGuard interface address space is unchanged let get_iface_req = Request::builder() .uri(format!("/api/v1/interfaces/{iface_id}")) .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(); let resp = app.oneshot(get_iface_req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let iface_after: Value = serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap(); assert_eq!(iface_after["name"], "wg0"); assert_eq!(iface_after["address_v4"], "10.100.0.1/24"); }