722 lines
23 KiB
Rust
722 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"));
|
|
|
|
// Peer enrollment must submit the selected Network UUID as network_id,
|
|
// never the display name or CIDR.
|
|
assert!(html.contains(r#"value="${n.id}""#));
|
|
assert!(html.contains("${escapeHtml(n.name)} (${n.cidr})"));
|
|
assert!(html.contains("network_id: networkId"));
|
|
assert!(html.contains("isNetworkUuid"));
|
|
assert!(html.contains("selectedNetwork.id"));
|
|
assert!(!html.contains("network: network || null"));
|
|
assert!(!html.contains(r#"value="${n.name}""#));
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|