Files
nx9-wg/crates/nx9-wg-api/tests/test_rest_api.rs
T
2026-09-02 15:19:19 +05:30

591 lines
21 KiB
Rust

use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode, header};
use nx9_wg_api::auth::{BootstrapOptions, bootstrap_admin};
use nx9_wg_api::routes::build_api_router;
use nx9_wg_api::state::AppState;
use nx9_wg_core::config::AppConfig;
use nx9_wg_db::Store;
use nx9_wg_network::SimulatedNetworkEngine;
use nx9_wireguard::SimulatedWireGuardEngine;
use serde_json::{Value, json};
use std::sync::Arc;
use tower::ServiceExt;
async fn setup_test_app() -> (axum::Router, String) {
let store = Store::connect_in_memory().await.expect("connect in-memory");
store.migrate().await.expect("migrate");
let config = AppConfig::default();
let opts = BootstrapOptions {
cli_password: Some("AdminSecret123!".to_string()),
..Default::default()
};
bootstrap_admin(&store, &config, &opts)
.await
.expect("bootstrap");
let state = AppState::with_engines(
store,
Arc::new(SimulatedWireGuardEngine::new()),
Arc::new(SimulatedNetworkEngine::new()),
);
let app = build_api_router(state.clone());
// Login to get session ID
let login_req = Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"username": "admin",
"password": "AdminSecret123!"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(login_req).await.expect("login request");
assert_eq!(resp.status(), StatusCode::OK);
let cookie_header = resp
.headers()
.get(header::SET_COOKIE)
.expect("set-cookie")
.to_str()
.unwrap();
let session_cookie = cookie_header.split(';').next().unwrap().to_string();
(app, session_cookie)
}
#[tokio::test]
async fn test_public_health_and_version_endpoints() {
let (app, _) = setup_test_app().await;
// Health
let req = Request::builder()
.uri("/api/v1/system/health")
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["status"], "healthy");
assert_eq!(val["database"], "connected");
// Version
let req = Request::builder()
.uri("/api/v1/system/version")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(val["name"], "nx9-wg");
assert_eq!(val["version"], "1.1.0");
}
#[tokio::test]
async fn test_protected_route_unauthenticated_rejection() {
let (app, _) = setup_test_app().await;
// Request protected route without auth
let req = Request::builder()
.uri("/api/v1/system")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_interfaces_and_peers_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// 1. Create interface
let create_iface_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg0",
"listen_port": 51820,
"address_v4": "10.0.0.1/24",
"dns": "1.1.1.1"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let iface_val: Value = serde_json::from_slice(&body).unwrap();
let iface_id = iface_val["id"].as_str().unwrap();
assert_eq!(iface_val["name"], "wg0");
// 2. List interfaces
let list_req = Request::builder()
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(list_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 3. Create peer under interface
let create_peer_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "laptop-alice",
"peer_type": "road_warrior",
"profile": "full_tunnel",
"allowed_ips": "10.0.0.2/32"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
let peer_id = peer_val["id"].as_str().unwrap();
assert_eq!(peer_val["name"], "laptop-alice");
// 4. Disable peer
let disable_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/peers/{peer_id}/disable"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(disable_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 5. Get peer and verify state
let get_peer_req = Request::builder()
.uri(format!("/api/v1/peers/{peer_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(get_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let peer_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(peer_val["state"], "disabled");
// 6. Delete wg0 interface (must be rejected with 403 Forbidden)
let del_wg0_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{iface_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del_wg0_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
// 7. Restart wg0 interface (must succeed)
let restart_wg0_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/restart"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(restart_wg0_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// 8. Create secondary interface and delete it (must succeed)
let create_sec_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "custom0",
"listen_port": 51822,
"address_v4": "10.200.0.1/24"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_sec_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let sec_val: Value = serde_json::from_slice(&body).unwrap();
let sec_id = sec_val["id"].as_str().unwrap();
let del_sec_req = Request::builder()
.method("DELETE")
.uri(format!("/api/v1/interfaces/{sec_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(del_sec_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_networks_and_firewall_rest_lifecycle() {
let (app, cookie) = setup_test_app().await;
// Create network
let net_req = Request::builder()
.method("POST")
.uri("/api/v1/networks")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Management Network",
"cidr": "10.10.0.0/16",
"description": "Internal management"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(net_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
// Create firewall rule
let fw_req = Request::builder()
.method("POST")
.uri("/api/v1/firewall/rules")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "Allow HTTPS",
"direction": "in",
"action": "accept",
"protocol": "tcp",
"destination_port": 443,
"priority": 10
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(fw_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let rule_val: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(rule_val["name"], "Allow HTTPS");
assert_eq!(rule_val["priority"], 10);
}
#[tokio::test]
async fn test_list_all_peers_collection_endpoint() {
let (app, cookie) = setup_test_app().await;
// 1. Verify unauthenticated GET /api/v1/peers returns 401 Unauthorized
let unauth_req = Request::builder()
.uri("/api/v1/peers")
.body(Body::empty())
.unwrap();
let unauth_resp = app.clone().oneshot(unauth_req).await.unwrap();
assert_eq!(unauth_resp.status(), StatusCode::UNAUTHORIZED);
// 2. Create first interface (wg0)
let iface0_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg0",
"listen_port": 51820,
"address_v4": "10.100.0.1/24"
})
.to_string(),
))
.unwrap();
let iface0_resp = app.clone().oneshot(iface0_req).await.unwrap();
assert_eq!(iface0_resp.status(), StatusCode::OK);
let iface0_val: Value =
serde_json::from_slice(&to_bytes(iface0_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface0_id = iface0_val["id"].as_str().unwrap();
// 3. Create second interface (wg1)
let iface1_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg1",
"listen_port": 51821,
"address_v4": "10.200.0.1/24"
})
.to_string(),
))
.unwrap();
let iface1_resp = app.clone().oneshot(iface1_req).await.unwrap();
assert_eq!(iface1_resp.status(), StatusCode::OK);
let iface1_val: Value =
serde_json::from_slice(&to_bytes(iface1_resp.into_body(), usize::MAX).await.unwrap())
.unwrap();
let iface1_id = iface1_val["id"].as_str().unwrap();
// 4. Create 2 peers under wg0
let peer_alice_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-alice",
"allowed_ips": "10.100.0.2/32"
})
.to_string(),
))
.unwrap();
let resp_alice = app.clone().oneshot(peer_alice_req).await.unwrap();
assert_eq!(resp_alice.status(), StatusCode::OK);
let peer_bob_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-bob",
"allowed_ips": "10.100.0.3/32"
})
.to_string(),
))
.unwrap();
let resp_bob = app.clone().oneshot(peer_bob_req).await.unwrap();
assert_eq!(resp_bob.status(), StatusCode::OK);
// 5. Create 1 peer under wg1
let peer_charlie_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "peer-charlie",
"allowed_ips": "10.200.0.2/32"
})
.to_string(),
))
.unwrap();
let resp_charlie = app.clone().oneshot(peer_charlie_req).await.unwrap();
assert_eq!(resp_charlie.status(), StatusCode::OK);
// 6. Test GET /api/v1/peers (All peers across all interfaces)
let list_all_req = Request::builder()
.uri("/api/v1/peers")
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let list_all_resp = app.clone().oneshot(list_all_req).await.unwrap();
assert_eq!(list_all_resp.status(), StatusCode::OK);
let all_peers_bytes = to_bytes(list_all_resp.into_body(), usize::MAX)
.await
.unwrap();
let all_peers: Vec<Value> = serde_json::from_slice(&all_peers_bytes).unwrap();
assert_eq!(
all_peers.len(),
3,
"GET /api/v1/peers must return all 3 peers across both interfaces"
);
let peer_names: Vec<&str> = all_peers
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(peer_names.contains(&"peer-alice"));
assert!(peer_names.contains(&"peer-bob"));
assert!(peer_names.contains(&"peer-charlie"));
// Verify representative fields are present and valid
for p in &all_peers {
assert!(p["id"].is_string());
assert!(p["public_key"].is_string());
assert!(p["interface_id"].is_string());
assert!(p["state"].is_string());
assert!(p["allowed_ips"].is_string());
}
// 7. Verify interface-scoped endpoint still works and returns only that interface's peers
let list_iface0_req = Request::builder()
.uri(format!("/api/v1/interfaces/{iface0_id}/peers"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let iface0_peers_resp = app.clone().oneshot(list_iface0_req).await.unwrap();
assert_eq!(iface0_peers_resp.status(), StatusCode::OK);
let iface0_peers: Vec<Value> = serde_json::from_slice(
&to_bytes(iface0_peers_resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(iface0_peers.len(), 2, "wg0 must return exactly 2 peers");
let list_iface1_req = Request::builder()
.uri(format!("/api/v1/interfaces/{iface1_id}/peers"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let iface1_peers_resp = app.oneshot(list_iface1_req).await.unwrap();
assert_eq!(iface1_peers_resp.status(), StatusCode::OK);
let iface1_peers: Vec<Value> = serde_json::from_slice(
&to_bytes(iface1_peers_resp.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(iface1_peers.len(), 1, "wg1 must return exactly 1 peer");
assert_eq!(iface1_peers[0]["name"], "peer-charlie");
}
#[tokio::test]
async fn test_peer_creation_allocates_from_selected_network() {
let (app, cookie) = setup_test_app().await;
// Interface Network (WireGuard transport address space)
let create_iface_req = Request::builder()
.method("POST")
.uri("/api/v1/interfaces")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "wg0",
"listen_port": 51820,
"address_v4": "10.100.0.1/24"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let iface_val: Value =
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
let iface_id = iface_val["id"].as_str().unwrap().to_string();
assert_eq!(iface_val["address_v4"], "10.100.0.1/24");
// Subnet Network (peer allocation domain)
let create_net_req = Request::builder()
.method("POST")
.uri("/api/v1/networks")
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "mobile-clients",
"cidr": "10.100.2.0/24"
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(create_net_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let net_val: Value =
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
let network_id = net_val["id"].as_str().unwrap();
assert_eq!(net_val["cidr"], "10.100.2.0/24");
// Exact production enrollment payload: selected Subnet Network UUID as network_id.
let selected_peer_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "sunil-moto-mobile-network-01",
"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",
"network_id": network_id
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(selected_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let selected_peer: Value =
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
let selected_addr = selected_peer["address_v4"].as_str().unwrap();
assert_eq!(
selected_addr, "10.100.2.1/32",
"selected Network must allocate the first host of 10.100.2.0/24, got {selected_addr}"
);
assert!(
selected_addr.starts_with("10.100.2."),
"selected Network must allocate from 10.100.2.0/24, got {selected_addr}"
);
assert!(
!selected_addr.starts_with("10.100.0."),
"must not allocate from Interface Network 10.100.0.0/24 when a Subnet Network is selected, got {selected_addr}"
);
assert!(selected_addr.ends_with("/32"));
// network_id = null preserves existing fallback (Interface Network CIDR)
let fallback_peer_req = Request::builder()
.method("POST")
.uri(format!("/api/v1/interfaces/{iface_id}/peers"))
.header(header::COOKIE, &cookie)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"name": "bob-fallback",
"peer_type": "road_warrior",
"profile": "full_tunnel",
"network_id": null
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(fallback_peer_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let fallback_peer: Value =
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
let fallback_addr = fallback_peer["address_v4"].as_str().unwrap();
assert!(
fallback_addr.starts_with("10.100.0."),
"network_id=null must preserve fallback allocation from Interface Network 10.100.0.0/24, got {fallback_addr}"
);
assert!(
!fallback_addr.starts_with("10.100.2."),
"network_id=null must not allocate from a Subnet Network, got {fallback_addr}"
);
assert!(fallback_addr.ends_with("/32"));
// WireGuard interface address space is unchanged
let get_iface_req = Request::builder()
.uri(format!("/api/v1/interfaces/{iface_id}"))
.header(header::COOKIE, &cookie)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(get_iface_req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let iface_after: Value =
serde_json::from_slice(&to_bytes(resp.into_body(), usize::MAX).await.unwrap()).unwrap();
assert_eq!(iface_after["name"], "wg0");
assert_eq!(iface_after["address_v4"], "10.100.0.1/24");
}