Files
nx9-wg/crates/nx9-wg-api/tests/test_ui_endpoints.rs
T
2026-08-18 17:32:56 +05:30

712 lines
23 KiB
Rust

//! 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::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()
.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("<svg"));
// 6. UI creates Network, Route, and Firewall Rule
let net_payload = serde_json::json!({
"name": "office-lan",
"cidr": "192.168.10.0/24"
});
let res_net = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/networks")
.header(axum::http::header::COOKIE, &session_cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&net_payload).unwrap(),
))
.unwrap(),
)
.await
.expect("create network");
assert_eq!(res_net.status(), StatusCode::OK);
let route_payload = serde_json::json!({
"destination": "192.168.50.0/24",
"gateway": "10.100.0.2",
"metric": 100,
"enabled": true
});
let res_route = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/routes")
.header(axum::http::header::COOKIE, &session_cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&route_payload).unwrap(),
))
.unwrap(),
)
.await
.expect("create route");
assert_eq!(res_route.status(), StatusCode::OK);
let fw_payload = serde_json::json!({
"name": "allow-dns",
"protocol": "udp",
"action": "accept",
"port": "53",
"priority": 10,
"enabled": true
});
let res_fw = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/firewall/rules")
.header(axum::http::header::COOKIE, &session_cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&fw_payload).unwrap(),
))
.unwrap(),
)
.await
.expect("create firewall rule");
assert_eq!(res_fw.status(), StatusCode::OK);
// 7. UI inspects Reconciliation Plan (Drift detected)
let res_plan = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/reconcile/plan")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("get reconcile plan");
assert_eq!(res_plan.status(), StatusCode::OK);
let plan_bytes = to_bytes(res_plan.into_body(), 1024 * 1024).await.unwrap();
let plan_json: serde_json::Value = serde_json::from_slice(&plan_bytes).unwrap();
assert_eq!(plan_json["has_drift"], true);
// 8. UI executes Reconciliation Apply
let res_apply = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/reconcile/apply")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("apply reconcile");
assert!(
res_apply.status() == StatusCode::OK
|| res_apply.status() == StatusCode::INTERNAL_SERVER_ERROR,
"Apply must return 200 on privileged/simulated engine or 500 with descriptive error on unprivileged host"
);
// 9. UI inspects Diagnostics
let res_diag = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/diagnostics/all")
.header(axum::http::header::COOKIE, &session_cookie)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("get diagnostics");
assert_eq!(res_diag.status(), StatusCode::OK);
// 10. UI creates Backup snapshot
let res_backup = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/backups/create")
.header(axum::http::header::COOKIE, &session_cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&serde_json::json!({
"description": "Manual snapshot"
}))
.unwrap(),
))
.unwrap(),
)
.await
.expect("create backup");
assert_eq!(res_backup.status(), StatusCode::OK);
// 11. UI generates API Token and receives one-time raw token
let token_payload = serde_json::json!({
"name": "ci-token",
"expires_in_days": 14
});
let res_token = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/auth/tokens")
.header(axum::http::header::COOKIE, &session_cookie)
.header(axum::http::header::CONTENT_TYPE, "application/json")
.body(axum::body::Body::from(
serde_json::to_vec(&token_payload).unwrap(),
))
.unwrap(),
)
.await
.expect("create token");
assert_eq!(res_token.status(), StatusCode::OK);
let token_bytes = to_bytes(res_token.into_body(), 1024 * 1024).await.unwrap();
let token_json: serde_json::Value = serde_json::from_slice(&token_bytes).unwrap();
let raw_token = token_json["raw_token"]
.as_str()
.expect("raw token delivered");
assert!(!raw_token.is_empty());
// 12. Authenticate with newly generated API Token
let res_token_auth = app
.clone()
.oneshot(
Request::builder()
.uri("/api/v1/system")
.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {raw_token}"),
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.expect("token auth request");
assert_eq!(res_token_auth.status(), StatusCode::OK);
// 13. UI 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
.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"
);
}