Files
nx9-wg/crates/nx9-wg-api/tests/test_ui_endpoints.rs
T

467 lines
16 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;
#[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");
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();
// 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
.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);
}