feat: complete nx9-wg v0.8.0 platform
This commit is contained in:
1 parent
c75e5c4e71
commit
c8a9b7cde6
52 files changed
+7751
-725
No files matched your search
@@ -91,4 +91,376 @@ async fn test_ui_spa_index_and_stylesheet_endpoints() {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user