//! 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; 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"); 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)")); // 4. Verify embedded JavaScript contains all UI controllers and lifecycle methods assert!(html.contains("runReconciliationApply")); assert!(html.contains("openCreateInterfaceModal")); assert!(html.contains("openCreateNetworkModal")); assert!(html.contains("openCreateRouteModal")); assert!(html.contains("openCreateFirewallModal")); assert!(html.contains("openCreateTokenModal")); assert!(html.contains("openChangePasswordModal")); assert!(html.contains("toggleNatSetting")); assert!(html.contains("toggleForwardingSetting")); assert!(html.contains("triggerCreateBackup")); assert!(html.contains("openClientExportModal")); assert!(html.contains("openAddPeerModal")); } #[tokio::test] async fn test_ui_api_complete_functional_loop() { 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("AdminSecret123!".to_string()), ..Default::default() }; nx9_wg_api::auth::bootstrap_admin(&store, &config, &opts) .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); // 1. Initial admin bootstrap & login let login_payload = serde_json::json!({ "username": "admin", "password": "AdminSecret123!" }); 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( serde_json::to_vec(&login_payload).unwrap(), )) .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("session cookie") .to_str() .unwrap() .to_string(); let session_cookie = cookie_header.split(';').next().unwrap().to_string(); // 2. UI verifies Session info let res_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 .expect("session request"); assert_eq!(res_session.status(), StatusCode::OK); // 3. UI creates WireGuard Interface (wg0) let iface_payload = serde_json::json!({ "name": "wg0", "listen_port": 51820, "address_v4": "10.100.0.1/24", "mtu": 1420 }); let res_iface = app .clone() .oneshot( Request::builder() .method("POST") .uri("/api/v1/interfaces") .header(axum::http::header::COOKIE, &session_cookie) .header(axum::http::header::CONTENT_TYPE, "application/json") .body(axum::body::Body::from( serde_json::to_vec(&iface_payload).unwrap(), )) .unwrap(), ) .await .expect("create interface"); assert_eq!(res_iface.status(), StatusCode::OK); let iface_body = to_bytes(res_iface.into_body(), 1024 * 1024).await.unwrap(); let iface_json: serde_json::Value = serde_json::from_slice(&iface_body).unwrap(); let iface_id = iface_json["id"].as_str().unwrap(); // 4. UI creates Peer on interface let peer_payload = serde_json::json!({ "name": "alice-phone", "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" }); let res_peer = app .clone() .oneshot( Request::builder() .method("POST") .uri(format!("/api/v1/interfaces/{iface_id}/peers")) .header(axum::http::header::COOKIE, &session_cookie) .header(axum::http::header::CONTENT_TYPE, "application/json") .body(axum::body::Body::from( serde_json::to_vec(&peer_payload).unwrap(), )) .unwrap(), ) .await .expect("create peer"); assert_eq!(res_peer.status(), StatusCode::OK); let peer_body = to_bytes(res_peer.into_body(), 1024 * 1024).await.unwrap(); 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::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() .oneshot( Request::builder() .uri(format!( "/api/v1/peers/{peer_id}/config?device=android&connection=mobile" )) .header(axum::http::header::COOKIE, &session_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .expect("get client config"); assert_eq!(res_conf.status(), StatusCode::OK); let conf_bytes = to_bytes(res_conf.into_body(), 1024 * 1024).await.unwrap(); let conf_str = String::from_utf8_lossy(&conf_bytes); assert!(conf_str.contains("[Interface]")); assert!(conf_str.contains("[Peer]")); assert!(conf_str.contains("MTU = 1280")); let res_qr = app .clone() .oneshot( Request::builder() .uri(format!("/api/v1/peers/{peer_id}/qr?qr_format=svg")) .header(axum::http::header::COOKIE, &session_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .expect("get qr svg"); assert_eq!(res_qr.status(), StatusCode::OK); let qr_bytes = to_bytes(res_qr.into_body(), 1024 * 1024).await.unwrap(); let qr_svg = String::from_utf8_lossy(&qr_bytes); assert!(qr_svg.contains("