feat: add cluster discovery, failover, VM management, and console support
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
//! Integration tests for the Proxmox API client core.
|
||||
//!
|
||||
//! These tests exercise the generic `api_request` helper against a local HTTP
|
||||
//! mock server, covering auth header injection, response envelope unwrapping,
|
||||
//! error mapping, query parameters, and URL construction.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{api_request, AuthContext, AuthMode, Error};
|
||||
use reqwest::Client;
|
||||
use reqwest::Method as RMethod;
|
||||
|
||||
const TICKET: &str = "PVE:root@pam:abc123";
|
||||
const CSRF_TOKEN: &str = "csrf-token-123";
|
||||
|
||||
fn token_auth() -> AuthContext {
|
||||
AuthContext {
|
||||
mode: AuthMode::Token,
|
||||
token: Some("root@pam!test-token".to_string()),
|
||||
ticket: None,
|
||||
csrf_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn password_auth() -> AuthContext {
|
||||
AuthContext {
|
||||
mode: AuthMode::Password,
|
||||
token: None,
|
||||
ticket: Some(TICKET.to_string()),
|
||||
csrf_token: Some(CSRF_TOKEN.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_auth_sends_authorization_header_and_unwraps_envelope() {
|
||||
let server = MockServer::start();
|
||||
let expected = serde_json::json!({
|
||||
"version": "8.1.0",
|
||||
"release": "8.1",
|
||||
"repoid": "abc123",
|
||||
});
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/version")
|
||||
.header("Authorization", "PVEAPIToken=root@pam!test-token");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(serde_json::json!({ "data": expected }).to_string());
|
||||
});
|
||||
|
||||
let result = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result, expected);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_auth_sends_cookie_header_on_get() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/version")
|
||||
.header("Cookie", format!("PVEAuthCookie={}", TICKET));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.1.0"}}"#);
|
||||
});
|
||||
|
||||
let result = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&password_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result["version"], "8.1.0");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_auth_sends_cookie_and_csrf_headers_on_post() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve/qemu/100/status/start")
|
||||
.header("Cookie", format!("PVEAuthCookie={}", TICKET))
|
||||
.header("CSRFPreventionToken", CSRF_TOKEN);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let result = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::POST,
|
||||
"/nodes/pve/qemu/100/status/start",
|
||||
&password_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result, serde_json::Value::Null);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_message_is_mapped_to_api_error() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(500)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"message":"boom"}"#);
|
||||
});
|
||||
|
||||
let error = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "boom"),
|
||||
"expected ApiError with message 'boom', got: {}",
|
||||
error
|
||||
);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_params_are_included_in_request() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve/qemu")
|
||||
.query_param("node", "pve")
|
||||
.query_param("vmid", "100")
|
||||
.query_param("include-config", "true");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let query = [
|
||||
("node", "pve".to_string()),
|
||||
("vmid", "100".to_string()),
|
||||
("include-config", "true".to_string()),
|
||||
];
|
||||
let result = api_request(
|
||||
&Client::new(),
|
||||
&server.base_url(),
|
||||
RMethod::GET,
|
||||
"/nodes/pve/qemu",
|
||||
&token_auth(),
|
||||
&query,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result, serde_json::json!([]));
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_url_trailing_slash_is_stripped() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.1.0"}}"#);
|
||||
});
|
||||
|
||||
let base_url = format!("{}/", server.base_url());
|
||||
let result = api_request(
|
||||
&Client::new(),
|
||||
&base_url,
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(result["version"], "8.1.0");
|
||||
mock.assert();
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Integration tests for the real Proxmox data-read API calls and the
|
||||
//! VM/container lifecycle operations.
|
||||
//!
|
||||
//! Every request goes through `Connection::request`, so these tests cover URL
|
||||
//! construction, query parameters, auth header injection, response envelope
|
||||
//! unwrapping, and mapping into the public serde structs. Token-mode
|
||||
//! connections resolve the token from the config, so the read methods can be
|
||||
//! called directly on an added connection without `connect()`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at the mock server. `node` pins the storage node.
|
||||
async fn setup_manager(
|
||||
url: &str,
|
||||
token: &str,
|
||||
node: Option<&str>,
|
||||
) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: node.map(str::to_string),
|
||||
token: Some(token.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_nodes_maps_cluster_resources() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!nodes-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": 16,
|
||||
"mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64,
|
||||
"maxdisk": 858993459200u64, "uptime": 123456, "level": "",
|
||||
"id": "node/pve1", "type": "node"},
|
||||
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
|
||||
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
|
||||
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
|
||||
"id": "node/pve2", "type": "node"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let nodes = manager
|
||||
.get_nodes("conn")
|
||||
.await
|
||||
.expect("nodes should be fetched");
|
||||
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].node, "pve1");
|
||||
assert_eq!(nodes[0].status, "online");
|
||||
assert!((nodes[0].cpu - 0.12).abs() < 1e-9);
|
||||
assert_eq!(nodes[0].maxcpu, 16);
|
||||
assert_eq!(nodes[0].mem, 8589934592u64);
|
||||
assert_eq!(nodes[0].maxmem, 68719476736u64);
|
||||
assert_eq!(nodes[0].disk, 214748364800u64);
|
||||
assert_eq!(nodes[0].maxdisk, 858993459200u64);
|
||||
assert_eq!(nodes[0].uptime, 123456);
|
||||
assert_eq!(nodes[0].id, "node/pve1");
|
||||
assert_eq!(nodes[0].r#type, "node");
|
||||
assert_eq!(nodes[1].node, "pve2");
|
||||
assert_eq!(nodes[1].status, "offline");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_vms_uses_cluster_resources() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vms-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
|
||||
"node": "pve1", "cpu": 0.03, "cpus": 2, "maxcpu": 2,
|
||||
"mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64,
|
||||
"maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456,
|
||||
"diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod",
|
||||
"pid": 1234},
|
||||
// A stopped container: the API omits runtime stats.
|
||||
{"vmid": 201, "name": "ct01", "type": "lxc", "status": "stopped",
|
||||
"node": "pve2", "template": 1, "tags": ""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let vms = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect("VMs should be fetched");
|
||||
|
||||
assert_eq!(vms.len(), 2);
|
||||
assert_eq!(vms[0].vmid, 100);
|
||||
assert_eq!(vms[0].name.as_deref(), Some("web01"));
|
||||
assert_eq!(vms[0].r#type, "qemu");
|
||||
assert_eq!(vms[0].status, "running");
|
||||
assert_eq!(vms[0].node, "pve1");
|
||||
assert_eq!(vms[0].uptime, 99999);
|
||||
assert_eq!(vms[0].netin, 123);
|
||||
assert_eq!(vms[0].netout, 456);
|
||||
assert_eq!(vms[0].diskread, 789);
|
||||
assert_eq!(vms[0].diskwrite, 101112);
|
||||
assert_eq!(vms[0].tags.as_deref(), Some("prod"));
|
||||
assert_eq!(vms[0].pid, Some(1234));
|
||||
// Tolerance: fields omitted for the stopped LXC default instead of failing.
|
||||
assert_eq!(vms[1].r#type, "lxc");
|
||||
assert_eq!(vms[1].status, "stopped");
|
||||
assert_eq!(vms[1].uptime, 0);
|
||||
assert_eq!(vms[1].cpu, 0.0);
|
||||
assert_eq!(vms[1].netin, 0);
|
||||
assert_eq!(vms[1].pid, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_maps_cluster_resources() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!storage-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "storage");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"storage": "local", "node": "pve1", "type": "dir",
|
||||
"content": "iso,vztmpl", "enabled": 1, "shared": 0, "active": 1,
|
||||
"total": 858993459200u64, "used": 429496729600u64, "avail": 429496729600u64,
|
||||
"status": "available"},
|
||||
{"storage": "backup", "node": "pve1", "type": "nfs",
|
||||
"content": "backup", "enabled": 1, "shared": 1, "active": 1,
|
||||
"total": 1717986918400u64, "used": 644245094400u64,
|
||||
"avail": 1073741824000u64, "status": "available"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let storages = manager
|
||||
.get_storage("conn")
|
||||
.await
|
||||
.expect("storage should be fetched");
|
||||
|
||||
assert_eq!(storages.len(), 2);
|
||||
assert_eq!(storages[0].storage, "local");
|
||||
assert_eq!(storages[0].r#type, "dir");
|
||||
assert_eq!(storages[0].content, "iso,vztmpl");
|
||||
assert_eq!(storages[0].active, 1);
|
||||
assert_eq!(storages[0].enabled, 1);
|
||||
assert_eq!(storages[0].shared, 0);
|
||||
assert_eq!(storages[0].used, 429496729600u64);
|
||||
assert_eq!(storages[0].total, 858993459200u64);
|
||||
assert_eq!(storages[0].avail, 429496729600u64);
|
||||
assert_eq!(storages[0].node, "pve1");
|
||||
assert_eq!(storages[1].shared, 1);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_uses_configured_node() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!content-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "local:iso/debian-12.iso", "content": "iso",
|
||||
"ctime": 1700000000, "format": "iso", "size": 68719476736u64},
|
||||
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
|
||||
"ctime": 1700000001}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
assert_eq!(contents.len(), 2);
|
||||
assert_eq!(contents[0].volid, "local:iso/debian-12.iso");
|
||||
assert_eq!(contents[0].content, "iso");
|
||||
assert_eq!(contents[0].format.as_deref(), Some("iso"));
|
||||
assert_eq!(contents[0].size, Some(68719476736u64));
|
||||
// Tolerance: optional metadata is omitted for the second entry.
|
||||
assert_eq!(contents[1].format, None);
|
||||
assert_eq!(contents[1].subtype, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_content_falls_back_to_online_node() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!fallback-token";
|
||||
let nodes_mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"node": "pve1", "status": "online", "cpu": 0.0, "maxcpu": 8,
|
||||
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
|
||||
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
|
||||
"id": "node/pve1", "type": "node"},
|
||||
{"node": "pve2", "status": "offline", "cpu": 0.0, "maxcpu": 8,
|
||||
"mem": 0, "maxmem": 34359738368u64, "disk": 0,
|
||||
"maxdisk": 536870912000u64, "uptime": 0, "level": "",
|
||||
"id": "node/pve2", "type": "node"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let content_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"volid": "local:iso/debian.iso", "content": "iso",
|
||||
"ctime": 1700000000}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let contents = manager
|
||||
.get_storage_content("conn", "local")
|
||||
.await
|
||||
.expect("content should be fetched");
|
||||
|
||||
assert_eq!(contents.len(), 1);
|
||||
assert_eq!(contents[0].volid, "local:iso/debian.iso");
|
||||
nodes_mock.assert();
|
||||
content_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_storage_detail_maps_status() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!detail-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"storage": "local", "type": "dir", "content": "iso,vztmpl",
|
||||
"active": 1, "enabled": 1, "shared": 0, "used": 429496729600u64,
|
||||
"total": 858993459200u64, "avail": 429496729600u64, "node": "pve1"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let detail = manager
|
||||
.get_storage_detail("conn", "pve1", "local")
|
||||
.await
|
||||
.expect("detail should be fetched");
|
||||
|
||||
assert_eq!(detail.storage, "local");
|
||||
assert_eq!(detail.r#type, "dir");
|
||||
assert_eq!(detail.content, "iso,vztmpl");
|
||||
assert_eq!(detail.active, 1);
|
||||
assert_eq!(detail.enabled, 1);
|
||||
assert_eq!(detail.used, 429496729600u64);
|
||||
assert_eq!(detail.total, 858993459200u64);
|
||||
assert_eq!(detail.avail, 429496729600u64);
|
||||
assert_eq!(detail.node, "pve1");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_tasks_maps_cluster_tasks() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!tasks-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/tasks")
|
||||
.query_param("limit", "50");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"upid": "UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:",
|
||||
"node": "pve1", "pid": 1000, "pstart": 100, "starttime": 1700000000,
|
||||
"type": "qmpstart", "id": "100", "user": "root@pam",
|
||||
"status": "stopped", "endtime": 1700000100, "exitstatus": "OK"},
|
||||
{"upid": "UPID:pve2:00000002:00000002:5F000001:vzstart:201:root@pam:",
|
||||
"node": "pve2", "pid": 2000, "pstart": 200, "starttime": 1700000001,
|
||||
"type": "vzstart", "id": "201", "user": "root@pam"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let tasks = manager
|
||||
.get_tasks("conn")
|
||||
.await
|
||||
.expect("tasks should be fetched");
|
||||
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(
|
||||
tasks[0].upid,
|
||||
"UPID:pve1:00000001:00000001:5F000000:qmpstart:100:root@pam:"
|
||||
);
|
||||
assert_eq!(tasks[0].node, "pve1");
|
||||
assert_eq!(tasks[0].pid, 1000);
|
||||
assert_eq!(tasks[0].pstart, 100);
|
||||
assert_eq!(tasks[0].starttime, 1700000000);
|
||||
assert_eq!(tasks[0].r#type, "qmpstart");
|
||||
assert_eq!(tasks[0].id, "100");
|
||||
assert_eq!(tasks[0].user, "root@pam");
|
||||
assert_eq!(tasks[0].endtime, Some(1700000100));
|
||||
assert_eq!(tasks[0].status.as_deref(), Some("stopped"));
|
||||
assert_eq!(tasks[0].exitstatus.as_deref(), Some("OK"));
|
||||
// Tolerance: the second task has not finished yet.
|
||||
assert_eq!(tasks[1].endtime, None);
|
||||
assert_eq!(tasks[1].status, None);
|
||||
assert_eq!(tasks[1].exitstatus, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_cluster_status_builds_cluster() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!cluster-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"type": "cluster", "id": "cluster", "name": "proxmox-cluster",
|
||||
"nodes": 2},
|
||||
{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1,
|
||||
"local": 1, "ip": "10.0.0.1"},
|
||||
{"type": "node", "id": "node/pve2", "nodeid": 2, "online": 0,
|
||||
"ip": "10.0.0.2"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let status = manager
|
||||
.get_cluster_status("conn")
|
||||
.await
|
||||
.expect("cluster status should be fetched");
|
||||
|
||||
assert_eq!(status.r#type, "cluster");
|
||||
assert_eq!(status.name, "proxmox-cluster");
|
||||
assert_eq!(status.id, "cluster");
|
||||
let nodes = status.nodes.expect("nodes should be present");
|
||||
assert_eq!(nodes.len(), 2);
|
||||
assert_eq!(nodes[0].name, "pve1");
|
||||
assert_eq!(nodes[0].nodeid, 1);
|
||||
assert_eq!(nodes[0].online, 1);
|
||||
assert_eq!(nodes[0].local, Some(1));
|
||||
assert_eq!(nodes[0].ip.as_deref(), Some("10.0.0.1"));
|
||||
assert_eq!(nodes[1].name, "pve2");
|
||||
assert_eq!(nodes[1].online, 0);
|
||||
assert_eq!(nodes[1].local, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_cluster_status_falls_back_to_default_name() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"type": "node", "id": "node/pve1", "nodeid": 1, "online": 1}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!default-token", None).await;
|
||||
let status = manager
|
||||
.get_cluster_status("conn")
|
||||
.await
|
||||
.expect("status should be fetched");
|
||||
|
||||
assert_eq!(status.name, "default");
|
||||
assert_eq!(status.id, "");
|
||||
let nodes = status.nodes.expect("nodes should be present");
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].name, "pve1");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_uses_qemu_or_lxc_path() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!lifecycle-token";
|
||||
let start_qemu = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/status/start")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
let start_lxc = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/status/start");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
let stop_qemu = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/status/stop");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
|
||||
manager
|
||||
.start_vm("conn", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect("qemu start should succeed");
|
||||
manager
|
||||
.start_vm("conn", "pve1", 201, "lxc")
|
||||
.await
|
||||
.expect("lxc start should succeed");
|
||||
manager
|
||||
.stop_vm("conn", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect("qemu stop should succeed");
|
||||
|
||||
start_qemu.assert();
|
||||
start_lxc.assert();
|
||||
stop_qemu.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_with_invalid_type_errors() {
|
||||
let server = MockServer::start();
|
||||
let probe = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/kvm/100/status/start");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
|
||||
let error = manager
|
||||
.start_vm("conn", "pve1", 100, "kvm")
|
||||
.await
|
||||
.expect_err("invalid vm type must be rejected");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
||||
"expected InvalidUrl mentioning 'kvm', got: {}",
|
||||
error
|
||||
);
|
||||
assert_eq!(
|
||||
probe.hits(),
|
||||
0,
|
||||
"no HTTP request should be made for an invalid vm type"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_methods_error_with_connection_not_found() {
|
||||
let server = MockServer::start();
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token", None).await;
|
||||
|
||||
let error = manager
|
||||
.get_nodes("does-not-exist")
|
||||
.await
|
||||
.expect_err("unknown connection must error");
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
|
||||
"expected ConnectionNotFound, got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
let error = manager
|
||||
.get_vms("does-not-exist")
|
||||
.await
|
||||
.expect_err("unknown connection must error");
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
|
||||
"expected ConnectionNotFound, got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
let error = manager
|
||||
.start_vm("does-not-exist", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect_err("unknown connection must error");
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
|
||||
"expected ConnectionNotFound, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Integration tests for the console proxy API calls (VNC + terminal) and
|
||||
//! the WebSocket base URL derivation.
|
||||
//!
|
||||
//! The proxy calls go through `Connection::request`, so these tests cover URL
|
||||
//! construction, auth header injection, response envelope unwrapping, and
|
||||
//! mapping into the public serde structs. `get_websocket_url` only rewrites
|
||||
//! the stored endpoint URL, so it never hits the network. Token-mode
|
||||
//! connections resolve the token from the config, so the methods can be called
|
||||
//! directly on an added connection without `connect()`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at `url`.
|
||||
async fn setup_manager(url: &str, token: &str) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: None,
|
||||
token: Some(token.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_vnc_proxy_maps_response() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!vnc-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/vncproxy")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"ticket": "PVE:vnc:abc",
|
||||
"port": 6000,
|
||||
"cert": "MIIB..."
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
|
||||
let proxy = manager
|
||||
.create_vnc_proxy("conn", "pve1", 100)
|
||||
.await
|
||||
.expect("vnc proxy should be created");
|
||||
|
||||
assert_eq!(proxy.ticket, "PVE:vnc:abc");
|
||||
assert_eq!(proxy.port, 6000);
|
||||
assert_eq!(proxy.cert, "MIIB...");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_term_proxy_maps_response() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!term-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/termproxy")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"ticket": "PVE:term:xyz",
|
||||
"port": 6100
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token).await;
|
||||
let proxy = manager
|
||||
.create_term_proxy("conn", "pve1", 201)
|
||||
.await
|
||||
.expect("term proxy should be created");
|
||||
|
||||
assert_eq!(proxy.ticket, "PVE:term:xyz");
|
||||
assert_eq!(proxy.port, 6100);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_websocket_url_converts_https_to_wss() {
|
||||
// Explicit port on https.
|
||||
let (manager, _dir) = setup_manager("https://192.168.1.10:8006", "root@pam!ws-token").await;
|
||||
let url = manager
|
||||
.get_websocket_url("conn", "pve1")
|
||||
.await
|
||||
.expect("websocket url should be derived");
|
||||
assert_eq!(url, "wss://192.168.1.10:8006");
|
||||
|
||||
// No explicit port: the default wss port applies, so none is emitted.
|
||||
let (manager, _dir) = setup_manager("https://pve.lan", "root@pam!ws-token").await;
|
||||
let url = manager
|
||||
.get_websocket_url("conn", "pve1")
|
||||
.await
|
||||
.expect("websocket url should be derived");
|
||||
assert_eq!(url, "wss://pve.lan");
|
||||
|
||||
// Plain http maps to ws.
|
||||
let (manager, _dir) = setup_manager("http://10.0.0.5:8006", "root@pam!ws-token").await;
|
||||
let url = manager
|
||||
.get_websocket_url("conn", "pve1")
|
||||
.await
|
||||
.expect("websocket url should be derived");
|
||||
assert_eq!(url, "ws://10.0.0.5:8006");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_websocket_url_errors_on_invalid_scheme() {
|
||||
let (manager, _dir) = setup_manager("ftp://host", "root@pam!ws-token").await;
|
||||
let error = manager
|
||||
.get_websocket_url("conn", "pve1")
|
||||
.await
|
||||
.expect_err("non-http(s) scheme must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("ftp")),
|
||||
"expected InvalidUrl mentioning 'ftp', got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_connection_errors() {
|
||||
let server = MockServer::start();
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!missing-token").await;
|
||||
|
||||
let error = manager
|
||||
.create_vnc_proxy("does-not-exist", "pve1", 100)
|
||||
.await
|
||||
.expect_err("unknown connection must error");
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionNotFound(ref id) if id == "does-not-exist"),
|
||||
"expected ConnectionNotFound, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Integration tests for VM/container disk and network interface management.
|
||||
//!
|
||||
//! Every request goes through `Connection::request`, so these tests cover URL
|
||||
//! construction (including the `qemu`/`lxc` path segment), form encoding,
|
||||
//! config parsing, and slot allocation. Token-mode connections resolve the
|
||||
//! token from the config, so the methods can be called directly on an added
|
||||
//! connection without `connect()`.
|
||||
//!
|
||||
//! The form bodies are `application/x-www-form-urlencoded`, so values with
|
||||
//! special characters (`:`, `=`, `,`) appear percent-encoded in the raw body;
|
||||
//! `body_includes` assertions use the encoded form (e.g. `:` becomes `%3A`).
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{
|
||||
AddDiskConfig, AddNICConfig, ConnectionConfig, ConnectionManager, EditNICConfig,
|
||||
EndpointConfig, Error,
|
||||
};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at the mock server.
|
||||
async fn setup_manager(
|
||||
url: &str,
|
||||
token: &str,
|
||||
node: Option<&str>,
|
||||
) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: node.map(str::to_string),
|
||||
token: Some(token.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_disks_parses_config_strings() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!disks-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"scsi0": "local-lvm:vm-100-disk-0,size=32G,format=qcow2",
|
||||
"scsi1": "local-lvm:vm-100-disk-1,size=50G",
|
||||
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0",
|
||||
"memory": 2048
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let disks = manager
|
||||
.get_disks("conn", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect("disks should be fetched");
|
||||
|
||||
assert_eq!(disks.len(), 2);
|
||||
assert_eq!(disks[0].device, "scsi0");
|
||||
assert_eq!(disks[0].storage, "local-lvm");
|
||||
assert_eq!(disks[0].size, 34359738368);
|
||||
assert_eq!(disks[0].format, "qcow2");
|
||||
assert_eq!(disks[0].usage, None);
|
||||
// The second disk has no size/format attributes: size is 0, format empty.
|
||||
assert_eq!(disks[1].device, "scsi1");
|
||||
assert_eq!(disks[1].storage, "local-lvm");
|
||||
assert_eq!(disks[1].size, 53687091200);
|
||||
assert_eq!(disks[1].format, "");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_disk_picks_free_slot_and_posts() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-disk-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"scsi0": "local-lvm:vm-100-disk-0,size=32G"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
// urlencoded: the `:` in `local-lvm:64G` becomes `%3A`.
|
||||
.body_includes("scsi1=local-lvm%3A64G");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddDiskConfig {
|
||||
storage: "local-lvm".to_string(),
|
||||
size: 68719476736,
|
||||
bus_type: "scsi".to_string(),
|
||||
};
|
||||
manager
|
||||
.add_disk("conn", "pve1", 100, "qemu", config)
|
||||
.await
|
||||
.expect("disk should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_disk_posts_resize() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!resize-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(PUT)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/resize")
|
||||
.body_includes("disk=scsi0")
|
||||
.body_includes("size=50G");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.resize_disk("conn", "pve1", 100, "qemu", "scsi0", 53687091200)
|
||||
.await
|
||||
.expect("resize should succeed");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_disk_posts_delete() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!remove-disk-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(DELETE)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.body_includes("delete=scsi0");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.remove_disk("conn", "pve1", 100, "qemu", "scsi0")
|
||||
.await
|
||||
.expect("disk should be removed");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn move_disk_posts_move_disk() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!move-disk-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/move_disk")
|
||||
.body_includes("disk=scsi0")
|
||||
.body_includes("storage=local-zfs");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.move_disk("conn", "pve1", 100, "qemu", "scsi0", "local-zfs")
|
||||
.await
|
||||
.expect("disk should be moved");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_network_interfaces_parses_nets() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!nets-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0,tag=10,firewall=1,link_down=1",
|
||||
"net1": "e1000=11:22:33:44:55:66,bridge=vmbr1"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let nics = manager
|
||||
.get_network_interfaces("conn", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect("nics should be fetched");
|
||||
|
||||
assert_eq!(nics.len(), 2);
|
||||
assert_eq!(nics[0].name, "net0");
|
||||
assert_eq!(nics[0].model, "virtio");
|
||||
assert_eq!(nics[0].macaddr, "BC:24:11:AA:BB:CC");
|
||||
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
|
||||
assert_eq!(nics[0].tag, Some(10));
|
||||
assert_eq!(nics[0].firewall, Some(1));
|
||||
assert_eq!(nics[0].link_down, Some(1));
|
||||
// The second NIC has no optional attributes.
|
||||
assert_eq!(nics[1].name, "net1");
|
||||
assert_eq!(nics[1].model, "e1000");
|
||||
assert_eq!(nics[1].macaddr, "11:22:33:44:55:66");
|
||||
assert_eq!(nics[1].bridge.as_deref(), Some("vmbr1"));
|
||||
assert_eq!(nics[1].tag, None);
|
||||
assert_eq!(nics[1].firewall, None);
|
||||
assert_eq!(nics[1].link_down, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_nic_posts_form() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!add-nic-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"memory": 2048}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
// urlencoded: `=` and `,` become `%3D` and `%2C`.
|
||||
.body_includes("net0=virtio%3Drandom%2Cbridge%3Dvmbr0");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
model: "virtio".to_string(),
|
||||
macaddr: None,
|
||||
tag: None,
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.add_nic("conn", "pve1", 100, "qemu", config)
|
||||
.await
|
||||
.expect("nic should be added");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nic_preserves_model_and_mac() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!edit-nic-token";
|
||||
let get_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {"net0": "virtio=MAC,bridge=vmbr0"}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let post_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.body_includes("net0=virtio%3DMAC%2Cbridge%3Dvmbr1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let config = EditNICConfig {
|
||||
bridge: Some("vmbr1".to_string()),
|
||||
model: None,
|
||||
tag: None,
|
||||
firewall: None,
|
||||
};
|
||||
manager
|
||||
.edit_nic("conn", "pve1", 100, "qemu", "net0", config)
|
||||
.await
|
||||
.expect("nic should be edited");
|
||||
|
||||
get_mock.assert();
|
||||
post_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_nic_posts_delete() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!remove-nic-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(DELETE)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/config")
|
||||
.body_includes("delete=net0");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.remove_nic("conn", "pve1", 100, "qemu", "net0")
|
||||
.await
|
||||
.expect("nic should be removed");
|
||||
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lxc_vm_types_use_lxc_path() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!lxc-token";
|
||||
let config_mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": {
|
||||
"rootfs": "local:vm-201-disk-0,size=8G",
|
||||
"mp0": "local:vm-201-mp-0,size=4G",
|
||||
"net0": "name=eth0,bridge=vmbr0,ip=dhcp"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
let resize_mock = server.mock(|when, then| {
|
||||
when.method(PUT)
|
||||
.path("/api2/json/nodes/pve1/lxc/201/resize")
|
||||
.body_includes("disk=rootfs")
|
||||
.body_includes("size=16G");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
|
||||
// Containers use `rootfs`/`mpN` keys rather than `scsiN`/`virtioN`; they
|
||||
// are not returned by get_disks, and network keys without an explicit
|
||||
// model are still parsed.
|
||||
let disks = manager
|
||||
.get_disks("conn", "pve1", 201, "lxc")
|
||||
.await
|
||||
.expect("disks should be fetched");
|
||||
assert!(
|
||||
disks.is_empty(),
|
||||
"container disks are not in the scsi/virtio/... key set"
|
||||
);
|
||||
|
||||
let nics = manager
|
||||
.get_network_interfaces("conn", "pve1", 201, "lxc")
|
||||
.await
|
||||
.expect("nics should be fetched");
|
||||
assert_eq!(nics.len(), 1);
|
||||
assert_eq!(nics[0].name, "net0");
|
||||
assert_eq!(nics[0].bridge.as_deref(), Some("vmbr0"));
|
||||
|
||||
manager
|
||||
.resize_disk("conn", "pve1", 201, "lxc", "rootfs", 17179869184)
|
||||
.await
|
||||
.expect("resize should succeed");
|
||||
|
||||
config_mock.assert_calls(2);
|
||||
resize_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_vm_type_errors() {
|
||||
let server = MockServer::start();
|
||||
let probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/kvm/100/config");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{}}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
|
||||
|
||||
let error = manager
|
||||
.get_disks("conn", "pve1", 100, "kvm")
|
||||
.await
|
||||
.expect_err("invalid vm type must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
||||
"expected InvalidUrl mentioning 'kvm', got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
let config = AddNICConfig {
|
||||
bridge: "vmbr0".to_string(),
|
||||
model: "virtio".to_string(),
|
||||
macaddr: None,
|
||||
tag: None,
|
||||
firewall: None,
|
||||
};
|
||||
let error = manager
|
||||
.add_nic("conn", "pve1", 100, "kvm", config)
|
||||
.await
|
||||
.expect_err("invalid vm type must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
||||
"expected InvalidUrl mentioning 'kvm', got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an invalid vm type"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Integration tests for the real snapshot, migration, and backup API calls.
|
||||
//!
|
||||
//! Every request goes through `Connection::request`, so these tests cover URL
|
||||
//! construction, form encoding, auth header injection, response envelope
|
||||
//! unwrapping, and mapping into the public serde structs. Token-mode
|
||||
//! connections resolve the token from the config, so the methods can be called
|
||||
//! directly on an added connection without `connect()`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{
|
||||
BackupJobConfig, ConnectionConfig, ConnectionManager, CreateSnapshotConfig, EndpointConfig,
|
||||
Error,
|
||||
};
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint points at the mock server. `node` pins the storage node.
|
||||
async fn setup_manager(
|
||||
url: &str,
|
||||
token: &str,
|
||||
node: Option<&str>,
|
||||
) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: node.map(str::to_string),
|
||||
token: Some(token.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_snapshots_maps_list() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!snap-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"name": "snap1", "description": "before upgrade",
|
||||
"snaptime": 1700000000, "vmstate": 1, "parent": "current"},
|
||||
{"name": "snap2", "snaptime": 1700000100, "vmstate": 0}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let snapshots = manager
|
||||
.get_snapshots("conn", "pve1", 100, "qemu")
|
||||
.await
|
||||
.expect("snapshots should be fetched");
|
||||
|
||||
assert_eq!(snapshots.len(), 2);
|
||||
assert_eq!(snapshots[0].name, "snap1");
|
||||
assert_eq!(snapshots[0].description, "before upgrade");
|
||||
assert_eq!(snapshots[0].snaptime, 1700000000);
|
||||
assert_eq!(snapshots[0].vmstate, 1);
|
||||
assert_eq!(snapshots[0].parent.as_deref(), Some("current"));
|
||||
// Tolerance: the second snapshot omits optional fields.
|
||||
assert_eq!(snapshots[1].name, "snap2");
|
||||
assert_eq!(snapshots[1].description, "");
|
||||
assert_eq!(snapshots[1].parent, None);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_snapshot_posts_form() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!snap-create-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/snapshot")
|
||||
.body_includes("snapname=snap1")
|
||||
.body_includes("vmstate=1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.create_snapshot(
|
||||
"conn",
|
||||
"pve1",
|
||||
100,
|
||||
"qemu",
|
||||
CreateSnapshotConfig {
|
||||
name: "snap1".to_string(),
|
||||
description: Some("before upgrade".to_string()),
|
||||
vmstate: Some(true),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("snapshot should be created");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_snapshot_deletes_path() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!snap-delete-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(DELETE)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.delete_snapshot("conn", "pve1", 100, "qemu", "snap1")
|
||||
.await
|
||||
.expect("snapshot should be deleted");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_snapshot_posts_rollback() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!snap-rollback-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/snapshot/snap1/rollback");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.rollback_snapshot("conn", "pve1", 100, "qemu", "snap1")
|
||||
.await
|
||||
.expect("rollback should succeed");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrate_vm_posts_migrate() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!migrate-token";
|
||||
let online_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/migrate")
|
||||
.body_includes("target=pve2")
|
||||
.body_includes("online=1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
let offline_mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/nodes/pve1/qemu/100/migrate")
|
||||
.body_includes("target=pve2")
|
||||
.body_includes("online=0");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", true)
|
||||
.await
|
||||
.expect("online migration should succeed");
|
||||
manager
|
||||
.migrate_vm("conn", "pve1", 100, "qemu", "pve2", false)
|
||||
.await
|
||||
.expect("offline migration should succeed");
|
||||
online_mock.assert();
|
||||
offline_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backup_jobs_maps_list() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!jobs-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/backup")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"id": "backup-1", "store": "backup", "schedule": "0 2 * * *",
|
||||
"all": 1, "enabled": 1, "node": "pve1", "compress": "zstd",
|
||||
"mode": "snapshot", "quiet": 0},
|
||||
{"id": "backup-2", "store": "local", "schedule": "30 3 * * 1",
|
||||
"all": 0, "enabled": 0, "vmid": "100,101"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
let jobs = manager
|
||||
.get_backup_jobs("conn")
|
||||
.await
|
||||
.expect("backup jobs should be fetched");
|
||||
|
||||
assert_eq!(jobs.len(), 2);
|
||||
assert_eq!(jobs[0].id, "backup-1");
|
||||
assert_eq!(jobs[0].store, "backup");
|
||||
assert_eq!(jobs[0].schedule, "0 2 * * *");
|
||||
assert_eq!(jobs[0].all, 1);
|
||||
assert_eq!(jobs[0].enabled, 1);
|
||||
assert_eq!(jobs[0].node.as_deref(), Some("pve1"));
|
||||
assert_eq!(jobs[0].compress.as_deref(), Some("zstd"));
|
||||
assert_eq!(jobs[0].mode.as_deref(), Some("snapshot"));
|
||||
assert_eq!(jobs[0].quiet, Some(0));
|
||||
// Tolerance: the second job omits the optional node/mode fields.
|
||||
assert_eq!(jobs[1].node, None);
|
||||
assert_eq!(jobs[1].compress, None);
|
||||
assert_eq!(jobs[1].mode, None);
|
||||
assert_eq!(jobs[1].quiet, None);
|
||||
assert_eq!(jobs[1].vmid.as_deref(), Some("100,101"));
|
||||
assert_eq!(jobs[1].all, 0);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_backup_job_posts_form() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!job-create-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api2/json/cluster/backup")
|
||||
.body_includes("schedule=0+2+*+*+*")
|
||||
.body_includes("storage=backup")
|
||||
.body_includes("mode=snapshot")
|
||||
.body_includes("compress=zstd")
|
||||
.body_includes("all=1")
|
||||
.body_includes("enabled=1")
|
||||
.body_includes("vmid=100%2C101")
|
||||
.body_includes("node=pve1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.create_backup_job(
|
||||
"conn",
|
||||
BackupJobConfig {
|
||||
id: None,
|
||||
storage: "backup".to_string(),
|
||||
schedule: "0 2 * * *".to_string(),
|
||||
mode: "snapshot".to_string(),
|
||||
compression: "zstd".to_string(),
|
||||
all: true,
|
||||
vmid: Some("100,101".to_string()),
|
||||
enabled: true,
|
||||
node: Some("pve1".to_string()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("backup job should be created");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_backup_job_deletes_path() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!job-delete-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(DELETE).path("/api2/json/cluster/backup/job-id");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, None).await;
|
||||
manager
|
||||
.delete_backup_job("conn", "job-id")
|
||||
.await
|
||||
.expect("backup job should be deleted");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_filters_and_maps() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [
|
||||
{"volid": "local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backupid": "vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
"backup-type": "qemu", "backup-id": "100",
|
||||
"backup-time": 1700000000, "storage": "local",
|
||||
"size": 1073741824u64, "ctime": 1700000001, "content": "backup"},
|
||||
{"volid": "local:iso/debian-12.iso", "content": "iso",
|
||||
"ctime": 1700000002},
|
||||
{"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl",
|
||||
"ctime": 1700000003}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", Some("local"))
|
||||
.await
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(
|
||||
backups[0].volid,
|
||||
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
assert_eq!(
|
||||
backups[0].backupid,
|
||||
"vzdump-qemu-100-2024_01_01-00_00_00.vma.zst"
|
||||
);
|
||||
assert_eq!(backups[0].backup_type, "qemu");
|
||||
assert_eq!(backups[0].backup_id, "100");
|
||||
assert_eq!(backups[0].backup_time, 1700000000);
|
||||
assert_eq!(backups[0].storage, "local");
|
||||
assert_eq!(backups[0].size, 1073741824u64);
|
||||
assert_eq!(backups[0].ctime, 1700000001);
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_backups_defaults_to_local_storage() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backups-default-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/storage/local/content")
|
||||
.query_param("content", "backup");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
let backups = manager
|
||||
.get_backups("conn", None)
|
||||
.await
|
||||
.expect("backups should be fetched");
|
||||
|
||||
assert!(backups.is_empty());
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_backup_url_encodes_volid() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!backup-delete-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(DELETE).path(
|
||||
"/api2/json/nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
);
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":null}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await;
|
||||
manager
|
||||
.delete_backup(
|
||||
"conn",
|
||||
"local:backup/vzdump-qemu-100-2024_01_01-00_00_00.vma.zst",
|
||||
)
|
||||
.await
|
||||
.expect("backup should be deleted");
|
||||
mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_vm_type_errors() {
|
||||
let server = MockServer::start();
|
||||
let probe = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/nodes/pve1/kvm/100/snapshot");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let (manager, _dir) = setup_manager(&server.base_url(), "root@pam!bad-type-token", None).await;
|
||||
|
||||
let error = manager
|
||||
.get_snapshots("conn", "pve1", 100, "kvm")
|
||||
.await
|
||||
.expect_err("invalid vm type must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
||||
"expected InvalidUrl mentioning 'kvm', got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
let error = manager
|
||||
.migrate_vm("conn", "pve1", 100, "kvm", "pve2", true)
|
||||
.await
|
||||
.expect_err("invalid vm type must be rejected");
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(ref message) if message.contains("kvm")),
|
||||
"expected InvalidUrl mentioning 'kvm', got: {}",
|
||||
error
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
probe.calls(),
|
||||
0,
|
||||
"no HTTP request should be made for an invalid vm type"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! Integration tests for the connect-time node discovery, same-cluster
|
||||
//! merging, and the connection status reporting flow.
|
||||
//!
|
||||
//! `connect` now authenticates, resets failover state, and discovers the
|
||||
//! cluster's nodes before either folding the connection into an existing
|
||||
//! same-cluster connection or marking it connected. These tests cover the
|
||||
//! discovered-node persistence, the merge of a second node's connection into
|
||||
//! the first, the status reporting (including failover and disconnected), and
|
||||
//! the all-endpoints-down case where the connect reports `"failed"` instead of
|
||||
//! erroring.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use httpmock::Mock;
|
||||
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
|
||||
const TOKEN: &str = "root@pam!cluster-token";
|
||||
|
||||
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
|
||||
/// are included.
|
||||
fn node_json(name: &str, status: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"node": name,
|
||||
"status": status,
|
||||
"cpu": 0.0,
|
||||
"maxcpu": 8,
|
||||
"mem": 0,
|
||||
"maxmem": 34359738368u64,
|
||||
"disk": 0,
|
||||
"maxdisk": 536870912000u64,
|
||||
"uptime": 0,
|
||||
"level": "",
|
||||
"id": format!("node/{}", name),
|
||||
"type": "node"
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
|
||||
/// the given node entries.
|
||||
fn cluster_status_body(
|
||||
cluster_id: &str,
|
||||
cluster_name: &str,
|
||||
nodes: &[serde_json::Value],
|
||||
) -> String {
|
||||
let mut entries = Vec::with_capacity(nodes.len() + 1);
|
||||
entries.push(serde_json::json!({
|
||||
"type": "cluster",
|
||||
"id": cluster_id,
|
||||
"name": cluster_name,
|
||||
"nodes": nodes.len()
|
||||
}));
|
||||
entries.extend_from_slice(nodes);
|
||||
serde_json::json!({ "data": entries }).to_string()
|
||||
}
|
||||
|
||||
fn stub_version<'a>(server: &'a MockServer) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.2"}}"#);
|
||||
})
|
||||
}
|
||||
|
||||
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(serde_json::json!({ "data": nodes }).to_string());
|
||||
})
|
||||
}
|
||||
|
||||
fn stub_cluster_status<'a>(
|
||||
server: &'a MockServer,
|
||||
cluster_id: &str,
|
||||
cluster_name: &str,
|
||||
nodes: &[serde_json::Value],
|
||||
) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(cluster_status_body(cluster_id, cluster_name, nodes));
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
|
||||
/// ephemeral port, its address is captured, and the listener is dropped so any
|
||||
/// subsequent connection attempt is refused.
|
||||
fn closed_port_url() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let addr = listener.local_addr().expect("address should be available");
|
||||
drop(listener);
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn fallback_config(url: &str) -> EndpointConfig {
|
||||
EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: None,
|
||||
token: Some(TOKEN.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a token-mode connection config that skips certificate verification
|
||||
/// (the mock servers speak plain HTTP).
|
||||
fn token_config(id: &str, url: &str, fallbacks: Vec<EndpointConfig>) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: None,
|
||||
token: Some(TOKEN.to_string()),
|
||||
},
|
||||
fallbacks,
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_connection(
|
||||
manager: &mut ConnectionManager,
|
||||
path: &std::path::Path,
|
||||
config: ConnectionConfig,
|
||||
) {
|
||||
manager
|
||||
.add_connection(config, path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_discovers_and_persists_nodes() {
|
||||
let server = MockServer::start();
|
||||
let port = server.port();
|
||||
|
||||
stub_version(&server);
|
||||
stub_nodes(
|
||||
&server,
|
||||
&[node_json("pve1", "online"), node_json("pve2", "online")],
|
||||
);
|
||||
stub_cluster_status(
|
||||
&server,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
||||
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
||||
"online": 1, "local": 0, "ip": "10.0.0.6"}),
|
||||
],
|
||||
);
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config("conn", &server.base_url(), vec![]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = manager
|
||||
.connect("conn", &path)
|
||||
.await
|
||||
.expect("connect should succeed");
|
||||
assert_eq!(result.status, "connected");
|
||||
assert_eq!(result.merged_into, None);
|
||||
assert_eq!(result.connection_id, "conn");
|
||||
|
||||
let config = manager
|
||||
.connection_config("conn")
|
||||
.expect("config should be readable");
|
||||
assert_eq!(config.nodes.len(), 2);
|
||||
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
|
||||
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
|
||||
|
||||
// The discovered nodes and cluster identity are persisted.
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
let conn_json = &json["connections"][0];
|
||||
assert_eq!(conn_json["clusterId"], "cluster/lab");
|
||||
assert_eq!(
|
||||
conn_json["nodes"]
|
||||
.as_array()
|
||||
.expect("nodes should be present")
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
conn_json["nodes"][0]["url"],
|
||||
format!("http://10.0.0.5:{}", port),
|
||||
"the primary node's URL must be persisted"
|
||||
);
|
||||
assert_eq!(conn_json["status"], "connected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_second_node_merges_into_existing() {
|
||||
let server_a = MockServer::start();
|
||||
let server_b = MockServer::start();
|
||||
|
||||
// Both servers report the same cluster identity, as two nodes of one
|
||||
// cluster would.
|
||||
for (server, ip1, ip2) in [
|
||||
(&server_a, "10.0.0.5", "10.0.0.6"),
|
||||
(&server_b, "10.0.0.7", "10.0.0.8"),
|
||||
] {
|
||||
stub_version(server);
|
||||
stub_nodes(
|
||||
server,
|
||||
&[node_json("pve1", "online"), node_json("pve2", "online")],
|
||||
);
|
||||
stub_cluster_status(
|
||||
server,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": ip1}),
|
||||
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
||||
"online": 1, "local": 0, "ip": ip2}),
|
||||
],
|
||||
);
|
||||
}
|
||||
let vms_mock = server_a.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
|
||||
"status": "running", "node": "pve1"}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config("conn-a", &server_a.base_url(), vec![]),
|
||||
)
|
||||
.await;
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config("conn-b", &server_b.base_url(), vec![]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let first = manager
|
||||
.connect("conn-a", &path)
|
||||
.await
|
||||
.expect("first connect should succeed");
|
||||
assert_eq!(first.status, "connected");
|
||||
assert_eq!(first.merged_into, None);
|
||||
|
||||
let second = manager
|
||||
.connect("conn-b", &path)
|
||||
.await
|
||||
.expect("second connect should succeed");
|
||||
assert_eq!(second.status, "connected");
|
||||
assert_eq!(second.connection_id, "conn-a");
|
||||
assert_eq!(second.merged_into.as_deref(), Some("conn-a"));
|
||||
|
||||
// The merged connection is gone and conn-a carries B's endpoint.
|
||||
assert!(
|
||||
matches!(
|
||||
manager.connection_config("conn-b"),
|
||||
Err(Error::ConnectionNotFound(ref id)) if id == "conn-b"
|
||||
),
|
||||
"conn-b must be removed after merging"
|
||||
);
|
||||
let config_a = manager
|
||||
.connection_config("conn-a")
|
||||
.expect("config should be readable");
|
||||
assert!(
|
||||
config_a
|
||||
.fallbacks
|
||||
.iter()
|
||||
.any(|endpoint| endpoint.url == server_b.base_url()),
|
||||
"conn-a must have B's URL as a fallback"
|
||||
);
|
||||
assert_eq!(
|
||||
config_a.nodes.len(),
|
||||
4,
|
||||
"the node lists of both servers must be merged"
|
||||
);
|
||||
|
||||
// Only the surviving connection is persisted.
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
let ids: Vec<&str> = json["connections"]
|
||||
.as_array()
|
||||
.expect("connections should be an array")
|
||||
.iter()
|
||||
.filter_map(|c| c["id"].as_str())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["conn-a"]);
|
||||
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn-a")
|
||||
.expect("status should be readable"),
|
||||
"connected"
|
||||
);
|
||||
|
||||
// A subsequent data request on the merged connection is served by A.
|
||||
let vms = manager
|
||||
.get_vms("conn-a")
|
||||
.await
|
||||
.expect("get_vms should succeed via A");
|
||||
assert_eq!(vms.len(), 1);
|
||||
vms_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_info_reports_failover() {
|
||||
let fallback = MockServer::start();
|
||||
stub_version(&fallback);
|
||||
stub_nodes(&fallback, &[node_json("pve1", "online")]);
|
||||
stub_cluster_status(
|
||||
&fallback,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
||||
],
|
||||
);
|
||||
let vms_mock = fallback.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"data": [{"vmid": 100, "name": "web01", "type": "qemu",
|
||||
"status": "running", "node": "pve1"}]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let primary_url = closed_port_url();
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config(
|
||||
"conn",
|
||||
&primary_url,
|
||||
vec![fallback_config(&fallback.base_url())],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = manager
|
||||
.connect("conn", &path)
|
||||
.await
|
||||
.expect("connect should succeed via the fallback");
|
||||
assert_eq!(result.status, "connected");
|
||||
|
||||
let vms = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect("get_vms should succeed via the fallback");
|
||||
assert_eq!(vms.len(), 1);
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn")
|
||||
.expect("status should be readable"),
|
||||
"failover"
|
||||
);
|
||||
|
||||
let info = manager
|
||||
.status_info("conn")
|
||||
.await
|
||||
.expect("status info should be readable");
|
||||
assert_eq!(info.status, "failover");
|
||||
assert_eq!(info.current_endpoint_url, fallback.base_url());
|
||||
assert_eq!(info.primary_url, primary_url);
|
||||
vms_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_info_when_disconnected() {
|
||||
let server = MockServer::start();
|
||||
let version_mock = stub_version(&server);
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config("conn", &server.base_url(), vec![]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let info = manager
|
||||
.status_info("conn")
|
||||
.await
|
||||
.expect("status info should be readable");
|
||||
assert_eq!(info.status, "disconnected");
|
||||
assert_eq!(info.primary_url, server.base_url());
|
||||
assert_eq!(info.current_endpoint_url, server.base_url());
|
||||
assert!(info.nodes.is_empty());
|
||||
assert_eq!(
|
||||
version_mock.hits(),
|
||||
0,
|
||||
"a disconnected connection must not hit the network"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_all_endpoints_down_returns_failed_result() {
|
||||
let primary_url = closed_port_url();
|
||||
let fallback_url = closed_port_url();
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
add_connection(
|
||||
&mut manager,
|
||||
&path,
|
||||
token_config("conn", &primary_url, vec![fallback_config(&fallback_url)]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let result = manager
|
||||
.connect("conn", &path)
|
||||
.await
|
||||
.expect("an unreachable cluster must be reported, not errored");
|
||||
assert_eq!(result.status, "failed");
|
||||
assert_eq!(result.merged_into, None);
|
||||
assert_eq!(result.connection_id, "conn");
|
||||
|
||||
let config = manager
|
||||
.connection_config("conn")
|
||||
.expect("config should be readable");
|
||||
assert_eq!(config.status, "failed");
|
||||
|
||||
// The failed status is persisted so the connection stays tracked.
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
assert_eq!(json["connections"][0]["status"], "failed");
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Integration tests for cluster node auto-discovery.
|
||||
//!
|
||||
//! `discover_nodes` combines `/nodes` (the node list and statuses) with
|
||||
//! `/cluster/status` (cluster IPs, local flags, and cluster identity) and
|
||||
//! derives an endpoint URL per node from the connection's primary URL. These
|
||||
//! tests cover the URL derivation rules, primary-node marking, the config
|
||||
//! mutation, and the persist -> load round-trip.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use httpmock::Mock;
|
||||
use proxmox_desktop::{derive_node_url, ConnectionConfig, ConnectionManager, EndpointConfig};
|
||||
|
||||
const TOKEN: &str = "root@pam!discovery-token";
|
||||
|
||||
/// A minimal `/nodes` entry. The `Node` struct requires every field, so all
|
||||
/// are included.
|
||||
fn node_json(name: &str, status: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"node": name,
|
||||
"status": status,
|
||||
"cpu": 0.0,
|
||||
"maxcpu": 8,
|
||||
"mem": 0,
|
||||
"maxmem": 34359738368u64,
|
||||
"disk": 0,
|
||||
"maxdisk": 536870912000u64,
|
||||
"uptime": 0,
|
||||
"level": "",
|
||||
"id": format!("node/{}", name),
|
||||
"type": "node"
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint is the mock server. `node` pins `primary.node`.
|
||||
async fn setup_manager(
|
||||
server: &MockServer,
|
||||
node: Option<&str>,
|
||||
) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: server.base_url(),
|
||||
node: node.map(str::to_string),
|
||||
token: Some(TOKEN.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
/// Stubs `GET /api2/json/nodes` returning `nodes` (entries from `node_json`).
|
||||
fn stub_nodes<'a>(server: &'a MockServer, nodes: &[serde_json::Value]) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(serde_json::json!({ "data": nodes }).to_string());
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds the `/cluster/status` response body: one `cluster` entry followed by
|
||||
/// the given node entries.
|
||||
fn cluster_status_body(
|
||||
cluster_id: &str,
|
||||
cluster_name: &str,
|
||||
nodes: &[serde_json::Value],
|
||||
) -> String {
|
||||
let mut entries = Vec::with_capacity(nodes.len() + 1);
|
||||
entries.push(serde_json::json!({
|
||||
"type": "cluster",
|
||||
"id": cluster_id,
|
||||
"name": cluster_name,
|
||||
"nodes": nodes.len()
|
||||
}));
|
||||
entries.extend_from_slice(nodes);
|
||||
serde_json::json!({ "data": entries }).to_string()
|
||||
}
|
||||
|
||||
/// Stubs `GET /api2/json/cluster/status` returning the cluster entry plus the
|
||||
/// given node entries.
|
||||
fn stub_cluster_status<'a>(
|
||||
server: &'a MockServer,
|
||||
cluster_id: &str,
|
||||
cluster_name: &str,
|
||||
nodes: &[serde_json::Value],
|
||||
) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(cluster_status_body(cluster_id, cluster_name, nodes));
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derive_node_url_keeps_scheme_and_port() {
|
||||
assert_eq!(
|
||||
derive_node_url("https://10.0.0.5:8006", Some("10.0.0.6"), "pve1"),
|
||||
"https://10.0.0.6:8006"
|
||||
);
|
||||
// An empty IP falls back to the node name.
|
||||
assert_eq!(
|
||||
derive_node_url("http://pve.lan:8006", Some(""), "pve.lan"),
|
||||
"http://pve.lan:8006"
|
||||
);
|
||||
// A missing port defaults to pveproxy's 8006 for both schemes.
|
||||
assert_eq!(
|
||||
derive_node_url("https://pve.lan", Some("10.0.0.7"), "pve1"),
|
||||
"https://10.0.0.7:8006"
|
||||
);
|
||||
// An unparseable primary URL falls back to a best-effort https URL.
|
||||
assert_eq!(
|
||||
derive_node_url("not a url", Some("10.0.0.8"), "pve1"),
|
||||
"https://10.0.0.8:8006"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_nodes_builds_and_marks_primary() {
|
||||
let server = MockServer::start();
|
||||
let port = server.port();
|
||||
|
||||
let nodes_mock = stub_nodes(
|
||||
&server,
|
||||
&[node_json("pve1", "online"), node_json("pve2", "online")],
|
||||
);
|
||||
let cluster_mock = stub_cluster_status(
|
||||
&server,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
||||
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
||||
"online": 1, "local": 0, "ip": "10.0.0.6"}),
|
||||
],
|
||||
);
|
||||
|
||||
let (mut manager, _dir) = setup_manager(&server, Some("pve1")).await;
|
||||
let discovered = manager
|
||||
.discover_nodes("conn")
|
||||
.await
|
||||
.expect("discovery should succeed");
|
||||
|
||||
assert_eq!(discovered.len(), 2);
|
||||
assert_eq!(
|
||||
discovered[0].name, "pve1",
|
||||
"the primary node must sort first"
|
||||
);
|
||||
assert!(discovered[0].is_primary);
|
||||
assert!(discovered[0].local);
|
||||
assert_eq!(discovered[0].status, "online");
|
||||
assert_eq!(
|
||||
discovered[0].url,
|
||||
format!("http://10.0.0.5:{}", port),
|
||||
"pve1's URL keeps the scheme and port but uses the cluster IP"
|
||||
);
|
||||
assert_eq!(discovered[1].name, "pve2");
|
||||
assert!(!discovered[1].is_primary);
|
||||
assert!(!discovered[1].local);
|
||||
assert_eq!(discovered[1].url, format!("http://10.0.0.6:{}", port));
|
||||
|
||||
let config = manager
|
||||
.connection_config("conn")
|
||||
.expect("config should be readable");
|
||||
assert_eq!(config.cluster_name.as_deref(), Some("lab"));
|
||||
assert_eq!(config.cluster_id.as_deref(), Some("cluster/lab"));
|
||||
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
|
||||
assert_eq!(
|
||||
config.nodes, discovered,
|
||||
"the discovered list must be stored"
|
||||
);
|
||||
|
||||
nodes_mock.assert();
|
||||
cluster_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_nodes_persists_and_round_trips() {
|
||||
let server = MockServer::start();
|
||||
|
||||
let nodes_mock = stub_nodes(
|
||||
&server,
|
||||
&[node_json("pve1", "online"), node_json("pve2", "offline")],
|
||||
);
|
||||
let cluster_mock = stub_cluster_status(
|
||||
&server,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": "10.0.0.5"}),
|
||||
serde_json::json!({"type": "node", "id": "node/pve2", "nodeid": 2,
|
||||
"online": 0, "local": 0, "ip": "10.0.0.6"}),
|
||||
],
|
||||
);
|
||||
|
||||
let (mut manager, dir) = setup_manager(&server, Some("pve1")).await;
|
||||
let path = dir.path().join("connections.json");
|
||||
let discovered = manager
|
||||
.discover_nodes("conn")
|
||||
.await
|
||||
.expect("discovery should succeed");
|
||||
|
||||
// discover_nodes stores on the config but does not persist by itself; any
|
||||
// later persist (here via set_active_connection) writes the new fields.
|
||||
manager
|
||||
.set_active_connection("conn".to_string(), &path)
|
||||
.await
|
||||
.expect("active connection should be set");
|
||||
|
||||
let mut reloaded = ConnectionManager::new();
|
||||
let result = reloaded
|
||||
.load_connections(&path)
|
||||
.await
|
||||
.expect("connections should load");
|
||||
assert_eq!(result.connections.len(), 1);
|
||||
let loaded = &result.connections[0];
|
||||
assert_eq!(loaded.nodes, discovered, "discovered nodes must round-trip");
|
||||
assert_eq!(loaded.cluster_id.as_deref(), Some("cluster/lab"));
|
||||
assert_eq!(loaded.cluster_name.as_deref(), Some("lab"));
|
||||
|
||||
nodes_mock.assert();
|
||||
cluster_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_nodes_sets_primary_node_from_url_match() {
|
||||
let server = MockServer::start();
|
||||
// The mock server's host is used as the node's cluster IP so the derived
|
||||
// URL equals the primary URL.
|
||||
let host = server.host();
|
||||
|
||||
let nodes_mock = stub_nodes(&server, &[node_json("pve1", "online")]);
|
||||
let cluster_mock = stub_cluster_status(
|
||||
&server,
|
||||
"cluster/lab",
|
||||
"lab",
|
||||
&[
|
||||
serde_json::json!({"type": "node", "id": "node/pve1", "nodeid": 1,
|
||||
"online": 1, "local": 1, "ip": host}),
|
||||
],
|
||||
);
|
||||
|
||||
let (mut manager, _dir) = setup_manager(&server, None).await;
|
||||
let discovered = manager
|
||||
.discover_nodes("conn")
|
||||
.await
|
||||
.expect("discovery should succeed");
|
||||
|
||||
assert_eq!(discovered.len(), 1);
|
||||
assert_eq!(
|
||||
discovered[0].url,
|
||||
server.base_url(),
|
||||
"the derived URL must equal the primary URL"
|
||||
);
|
||||
assert!(
|
||||
discovered[0].is_primary,
|
||||
"a node whose URL matches the primary URL must be marked primary"
|
||||
);
|
||||
|
||||
// With primary.node unset, discovery pins it to the primary node.
|
||||
let config = manager
|
||||
.connection_config("conn")
|
||||
.expect("config should be readable");
|
||||
assert_eq!(config.primary.node.as_deref(), Some("pve1"));
|
||||
|
||||
nodes_mock.assert();
|
||||
cluster_mock.assert();
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Integration tests for automatic failover across cluster endpoints.
|
||||
//!
|
||||
//! Transport-level failures (connection refused, timeouts, DNS resolution)
|
||||
//! must be distinguishable from real authentication failures so the request
|
||||
//! core can rotate to the next configured endpoint. These tests cover the
|
||||
//! error mapping in `api_request` and the rotation logic in
|
||||
//! `Connection::request`.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use httpmock::Mock;
|
||||
use proxmox_desktop::{
|
||||
api_request, AuthContext, AuthMode, ConnectionConfig, ConnectionManager, EndpointConfig, Error,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use reqwest::Method as RMethod;
|
||||
|
||||
const TOKEN: &str = "root@pam!failover-token";
|
||||
|
||||
/// Returns a URL pointing at a closed TCP port: a listener is bound to an
|
||||
/// ephemeral port, its address is captured, and the listener is dropped so
|
||||
/// any subsequent connection attempt is refused.
|
||||
fn closed_port_url() -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind");
|
||||
let addr = listener.local_addr().expect("address should be available");
|
||||
drop(listener);
|
||||
format!("http://{}", addr)
|
||||
}
|
||||
|
||||
fn token_auth() -> AuthContext {
|
||||
AuthContext {
|
||||
mode: AuthMode::Token,
|
||||
token: Some(TOKEN.to_string()),
|
||||
ticket: None,
|
||||
csrf_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `ConnectionManager` with a single token-mode connection whose
|
||||
/// primary endpoint is `primary_url` and whose fallbacks are `fallback_urls`.
|
||||
async fn setup_manager_with_fallbacks(
|
||||
primary_url: &str,
|
||||
fallback_urls: &[String],
|
||||
) -> (ConnectionManager, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn".to_string(),
|
||||
name: "conn".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: primary_url.to_string(),
|
||||
node: None,
|
||||
token: Some(TOKEN.to_string()),
|
||||
},
|
||||
fallbacks: fallback_urls
|
||||
.iter()
|
||||
.map(|url| EndpointConfig {
|
||||
url: url.clone(),
|
||||
node: None,
|
||||
token: Some(TOKEN.to_string()),
|
||||
})
|
||||
.collect(),
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: true,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
(manager, dir)
|
||||
}
|
||||
|
||||
/// Stubs `GET /api2/json/cluster/resources?type=vm` on `server`.
|
||||
fn stub_vm_resources<'a>(server: &'a MockServer, status: u16, body: &str) -> Mock<'a> {
|
||||
server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/cluster/resources")
|
||||
.query_param("type", "vm");
|
||||
then.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(body);
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transport_error_maps_to_connection_failed() {
|
||||
let url = closed_port_url();
|
||||
let error = api_request(
|
||||
&Client::new(),
|
||||
&url,
|
||||
RMethod::GET,
|
||||
"/version",
|
||||
&token_auth(),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("connection refused must fail");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionFailed(_)),
|
||||
"expected ConnectionFailed, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_rotates_to_fallback_when_primary_down() {
|
||||
let fallback = MockServer::start();
|
||||
let fallback_mock = stub_vm_resources(
|
||||
&fallback,
|
||||
200,
|
||||
&serde_json::json!({
|
||||
"data": [
|
||||
{"vmid": 100, "name": "web01", "type": "qemu", "status": "running",
|
||||
"node": "pve1"}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let primary_url = closed_port_url();
|
||||
let (manager, _dir) = setup_manager_with_fallbacks(&primary_url, &[fallback.base_url()]).await;
|
||||
|
||||
let vms = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect("request should succeed via the fallback endpoint");
|
||||
|
||||
assert_eq!(vms.len(), 1);
|
||||
assert_eq!(vms[0].vmid, 100);
|
||||
assert_eq!(vms[0].name.as_deref(), Some("web01"));
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn")
|
||||
.expect("status should be readable"),
|
||||
"failover"
|
||||
);
|
||||
fallback_mock.assert();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_transport_error_does_not_rotate() {
|
||||
let primary = MockServer::start();
|
||||
let fallback = MockServer::start();
|
||||
let primary_mock = stub_vm_resources(&primary, 500, r#"{"message":"boom"}"#);
|
||||
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
|
||||
|
||||
let (manager, _dir) =
|
||||
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
|
||||
|
||||
let error = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect_err("a 500 response must surface as an ApiError");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::ApiError(ref message) if message == "boom"),
|
||||
"expected ApiError with message 'boom', got: {}",
|
||||
error
|
||||
);
|
||||
primary_mock.assert();
|
||||
assert_eq!(
|
||||
fallback_mock.hits(),
|
||||
0,
|
||||
"a non-transport error must not trigger failover"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_succeeds_on_primary_again_when_it_returns() {
|
||||
let primary = MockServer::start();
|
||||
let fallback = MockServer::start();
|
||||
let primary_mock = stub_vm_resources(&primary, 200, r#"{"data":[]}"#);
|
||||
let fallback_mock = stub_vm_resources(&fallback, 200, r#"{"data":[]}"#);
|
||||
|
||||
let (manager, _dir) =
|
||||
setup_manager_with_fallbacks(&primary.base_url(), &[fallback.base_url()]).await;
|
||||
|
||||
let vms = manager
|
||||
.get_vms("conn")
|
||||
.await
|
||||
.expect("request should succeed on the primary endpoint");
|
||||
|
||||
assert!(vms.is_empty());
|
||||
primary_mock.assert();
|
||||
assert_eq!(fallback_mock.hits(), 0);
|
||||
assert_eq!(
|
||||
manager
|
||||
.runtime_status("conn")
|
||||
.expect("status should be readable"),
|
||||
"connected"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
//! Integration tests for connection persistence and the connect/disconnect
|
||||
//! flow.
|
||||
//!
|
||||
//! Connection configs are persisted to a JSON file with secrets (API tokens)
|
||||
//! stripped, so they never round-trip through the file; after a restart the
|
||||
//! request layer falls back to the OS keyring.
|
||||
|
||||
use httpmock::prelude::*;
|
||||
use proxmox_desktop::{ConnectionConfig, ConnectionManager, EndpointConfig, Error};
|
||||
|
||||
/// Builds a token-mode connection config with a real token set.
|
||||
fn token_config(id: &str, url: &str, token: &str, accept_untrusted: bool) -> ConnectionConfig {
|
||||
ConnectionConfig {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: url.to_string(),
|
||||
node: None,
|
||||
token: Some(token.to_string()),
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persisted_configs_round_trip_without_secrets() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
let config = ConnectionConfig {
|
||||
id: "conn-1".to_string(),
|
||||
name: "Proxmox".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: "https://pve.local:8006".to_string(),
|
||||
node: None,
|
||||
token: Some("root@pam!primary-secret".to_string()),
|
||||
},
|
||||
fallbacks: vec![EndpointConfig {
|
||||
url: "https://backup.local:8006".to_string(),
|
||||
node: None,
|
||||
token: Some("root@pam!backup-secret".to_string()),
|
||||
}],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: false,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.add_connection(config, &path)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
|
||||
// The persisted file must contain the connection but never its secrets.
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
assert_eq!(json["activeConnectionId"], serde_json::Value::Null);
|
||||
let conn = &json["connections"][0];
|
||||
assert_eq!(conn["id"], "conn-1");
|
||||
assert_eq!(conn["name"], "Proxmox");
|
||||
assert!(
|
||||
conn["primary"].get("token").is_none(),
|
||||
"primary token must not be serialized"
|
||||
);
|
||||
assert!(
|
||||
conn["fallbacks"][0].get("token").is_none(),
|
||||
"fallback token must not be serialized"
|
||||
);
|
||||
assert!(
|
||||
conn.get("certFingerprint").is_none(),
|
||||
"absent certFingerprint must not be serialized"
|
||||
);
|
||||
|
||||
// Reloading from disk rebuilds the connection; the token does not
|
||||
// round-trip and every connection starts disconnected.
|
||||
let mut reloaded = ConnectionManager::new();
|
||||
let result = reloaded
|
||||
.load_connections(&path)
|
||||
.await
|
||||
.expect("connections should load");
|
||||
assert_eq!(result.connections.len(), 1);
|
||||
let loaded = &result.connections[0];
|
||||
assert_eq!(loaded.id, "conn-1");
|
||||
assert_eq!(loaded.name, "Proxmox");
|
||||
assert!(
|
||||
loaded.primary.token.is_none(),
|
||||
"token must not round-trip through the file"
|
||||
);
|
||||
assert_eq!(loaded.status, "disconnected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_connections_with_missing_file_returns_empty() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("missing").join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
let result = manager
|
||||
.load_connections(&path)
|
||||
.await
|
||||
.expect("missing file is not an error");
|
||||
assert!(result.active_connection_id.is_none());
|
||||
assert!(result.connections.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_connection_persists_and_keeps_active() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-1", "https://one.local:8006", "tok-1", false),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection 1 should be added");
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-2", "https://two.local:8006", "tok-2", false),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection 2 should be added");
|
||||
manager
|
||||
.set_active_connection("conn-2".to_string(), &path)
|
||||
.await
|
||||
.expect("active connection should be set");
|
||||
|
||||
manager
|
||||
.remove_connection("conn-1", &path)
|
||||
.await
|
||||
.expect("connection 1 should be removed");
|
||||
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
let ids: Vec<&str> = json["connections"]
|
||||
.as_array()
|
||||
.expect("connections should be an array")
|
||||
.iter()
|
||||
.filter_map(|c| c["id"].as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec!["conn-2"],
|
||||
"removed connection must not be persisted"
|
||||
);
|
||||
assert_eq!(
|
||||
json["activeConnectionId"], "conn-2",
|
||||
"removing a non-active connection must keep the active id"
|
||||
);
|
||||
|
||||
let mut reloaded = ConnectionManager::new();
|
||||
let result = reloaded
|
||||
.load_connections(&path)
|
||||
.await
|
||||
.expect("connections should load");
|
||||
assert_eq!(result.connections.len(), 1);
|
||||
assert_eq!(result.connections[0].id, "conn-2");
|
||||
assert_eq!(result.active_connection_id.as_deref(), Some("conn-2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_connection_preserves_cert_settings_and_replaces_config() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-1", "https://one.local:8006", "tok-1", true),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
manager
|
||||
.trust_certificate("conn-1", "AB:CD:EF", &path)
|
||||
.await
|
||||
.expect("certificate should be pinned");
|
||||
|
||||
// Update the name while omitting the certificate fields; the pinned
|
||||
// fingerprint and trust settings must survive the update.
|
||||
let updated = ConnectionConfig {
|
||||
id: "conn-1".to_string(),
|
||||
name: "Renamed".to_string(),
|
||||
primary: EndpointConfig {
|
||||
url: "https://one.local:8006".to_string(),
|
||||
node: None,
|
||||
token: None,
|
||||
},
|
||||
fallbacks: vec![],
|
||||
cert_fingerprint: None,
|
||||
trusted: false,
|
||||
accept_untrusted: false,
|
||||
status: "disconnected".to_string(),
|
||||
cluster_name: None,
|
||||
is_cluster: false,
|
||||
auth_mode: "token".to_string(),
|
||||
username: None,
|
||||
nodes: vec![],
|
||||
cluster_id: None,
|
||||
};
|
||||
manager
|
||||
.update_connection(updated, &path)
|
||||
.await
|
||||
.expect("connection should be updated");
|
||||
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
let conn = &json["connections"][0];
|
||||
assert_eq!(conn["id"], "conn-1");
|
||||
assert_eq!(conn["name"], "Renamed");
|
||||
assert_eq!(
|
||||
conn["certFingerprint"], "AB:CD:EF",
|
||||
"cert_fingerprint must be preserved when omitted"
|
||||
);
|
||||
assert_eq!(
|
||||
conn["trusted"], true,
|
||||
"trusted must be preserved when omitted"
|
||||
);
|
||||
assert_eq!(
|
||||
conn["acceptUntrusted"], true,
|
||||
"accept_untrusted must be preserved when omitted"
|
||||
);
|
||||
|
||||
// The updated config is what the reloaded manager serves.
|
||||
let mut reloaded = ConnectionManager::new();
|
||||
let result = reloaded
|
||||
.load_connections(&path)
|
||||
.await
|
||||
.expect("connections should load");
|
||||
assert_eq!(result.connections.len(), 1);
|
||||
assert_eq!(result.connections[0].name, "Renamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_connection_unknown_id_fails() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
let error = manager
|
||||
.update_connection(
|
||||
token_config("missing", "https://nope.local:8006", "tok", false),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect_err("updating an unknown connection must fail");
|
||||
assert!(
|
||||
matches!(error, Error::ConnectionNotFound(ref id) if id == "missing"),
|
||||
"expected ConnectionNotFound for the missing id, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_active_connection_clears_active_id() {
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-1", "https://one.local:8006", "tok-1", false),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
manager
|
||||
.set_active_connection("conn-1".to_string(), &path)
|
||||
.await
|
||||
.expect("active connection should be set");
|
||||
|
||||
manager
|
||||
.remove_connection("conn-1", &path)
|
||||
.await
|
||||
.expect("connection should be removed");
|
||||
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
assert!(
|
||||
json["activeConnectionId"].is_null(),
|
||||
"removing the active connection must clear the active id"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_token_mode_validates_version_endpoint() {
|
||||
let server = MockServer::start();
|
||||
let token = "root@pam!test-token";
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/api2/json/version")
|
||||
.header("Authorization", format!("PVEAPIToken={}", token));
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.2","release":"8.2.4","repoid":"abc"}}"#);
|
||||
});
|
||||
// Connect also discovers the cluster's nodes, so the discovery endpoints
|
||||
// are stubbed (empty cluster: no node entries, no cluster identity).
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
// `accept_untrusted` skips the TLS capture, which is required here because
|
||||
// the mock server speaks plain HTTP.
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-token", &server.base_url(), token, true),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
|
||||
let result = manager
|
||||
.connect("conn-token", &path)
|
||||
.await
|
||||
.expect("connect should succeed against the mock server");
|
||||
assert_eq!(result.status, "connected");
|
||||
assert_eq!(result.merged_into, None);
|
||||
mock.assert();
|
||||
|
||||
// The status update is persisted alongside the connection.
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
assert_eq!(
|
||||
json["connections"][0]["status"], "connected",
|
||||
"connect must persist the connected status"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_without_cert_pin_and_no_escape_hatch_fails() {
|
||||
let server = MockServer::start();
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-untrusted", &server.base_url(), "tok", false),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
|
||||
// The guard rejects the connect before any TLS is attempted (the guard is
|
||||
// hit regardless of the scheme, so the http:// mock URL is fine here).
|
||||
let error = manager
|
||||
.connect("conn-untrusted", &path)
|
||||
.await
|
||||
.expect_err("connect must fail without a pin or escape hatch");
|
||||
assert!(
|
||||
matches!(error, Error::CertificateError(ref message) if message.contains("not been trusted")),
|
||||
"expected CertificateError about the untrusted certificate, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_accept_untrusted_escape_hatch_reaches_version() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.2"}}"#);
|
||||
});
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-escape", &server.base_url(), "tok", true),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
|
||||
let result = manager
|
||||
.connect("conn-escape", &path)
|
||||
.await
|
||||
.expect("the escape hatch must skip certificate verification");
|
||||
assert_eq!(result.status, "connected");
|
||||
mock.assert();
|
||||
|
||||
let raw = std::fs::read_to_string(&path).expect("file should be written");
|
||||
let json: serde_json::Value = serde_json::from_str(&raw).expect("file should be valid JSON");
|
||||
assert_eq!(json["connections"][0]["status"], "connected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_clears_session_and_status() {
|
||||
let server = MockServer::start();
|
||||
let mock = server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/version");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":{"version":"8.2"}}"#);
|
||||
});
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/nodes");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
server.mock(|when, then| {
|
||||
when.method(GET).path("/api2/json/cluster/status");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(r#"{"data":[]}"#);
|
||||
});
|
||||
|
||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let path = dir.path().join("connections.json");
|
||||
|
||||
let mut manager = ConnectionManager::new();
|
||||
manager
|
||||
.add_connection(
|
||||
token_config("conn-disc", &server.base_url(), "tok", true),
|
||||
&path,
|
||||
)
|
||||
.await
|
||||
.expect("connection should be added");
|
||||
let result = manager
|
||||
.connect("conn-disc", &path)
|
||||
.await
|
||||
.expect("connect should succeed");
|
||||
assert_eq!(result.status, "connected");
|
||||
|
||||
manager
|
||||
.disconnect("conn-disc")
|
||||
.await
|
||||
.expect("disconnect should succeed");
|
||||
|
||||
// Reconnecting after a disconnect works, so the credentials used to
|
||||
// authenticate (the in-config token) must still be available. This
|
||||
// triggers a second request to the version endpoint.
|
||||
let result = manager
|
||||
.connect("conn-disc", &path)
|
||||
.await
|
||||
.expect("reconnect after disconnect should succeed");
|
||||
assert_eq!(result.status, "connected");
|
||||
mock.assert_hits(2);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Integration tests for the application-layer TLS certificate capture and
|
||||
//! TOFU (Trust On First Use) pinning helpers.
|
||||
//!
|
||||
//! Each test spawns a local TLS server with a freshly generated self-signed
|
||||
//! certificate and exercises the capture/verify helpers against it.
|
||||
|
||||
use proxmox_desktop::tls::{
|
||||
capture_fingerprint, fetch_certificate_info, verify_pin, verify_server_certificate,
|
||||
};
|
||||
use proxmox_desktop::Error;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use rustls::ServerConfig;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
|
||||
/// A plausible but wrong fingerprint (SHA-256 size, hex-uppercase, colon-separated).
|
||||
const WRONG_PIN: &str = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
|
||||
|
||||
/// Computes the expected `AA:BB:CC:...` uppercase fingerprint from raw DER.
|
||||
fn fingerprint_of(der: &[u8]) -> String {
|
||||
let digest = Sha256::digest(der);
|
||||
let hex = hex::encode_upper(digest);
|
||||
hex.as_bytes()
|
||||
.chunks(2)
|
||||
.map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
}
|
||||
|
||||
/// Generates a self-signed certificate and key for `127.0.0.1`.
|
||||
fn generate_self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) {
|
||||
let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()])
|
||||
.expect("self-signed cert generation should succeed");
|
||||
let cert_der = certified.cert.der().clone();
|
||||
let key_der =
|
||||
PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der()));
|
||||
(cert_der, key_der)
|
||||
}
|
||||
|
||||
/// Spawns a TLS server on an ephemeral port serving `cert_der`/`key_der` and
|
||||
/// returns the `https://` URL and the expected certificate fingerprint.
|
||||
async fn spawn_tls_server(
|
||||
cert_der: CertificateDer<'static>,
|
||||
key_der: PrivateKeyDer<'static>,
|
||||
) -> (String, String) {
|
||||
// rustls cannot auto-select a process-level CryptoProvider when the
|
||||
// dependency graph enables both `ring` (reqwest/tokio-rustls) and the
|
||||
// rustls default `aws-lc-rs`. The production capture helpers install ring
|
||||
// explicitly; the spawned server below must do the same or it races with
|
||||
// (and loses to) the client's install before building its config.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind should succeed");
|
||||
let address = listener.local_addr().expect("local addr should exist");
|
||||
|
||||
let config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
||||
.expect("server config should build");
|
||||
let acceptor = Arc::new(TlsAcceptor::from(Arc::new(config)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = match listener.accept().await {
|
||||
Ok(accepted) => accepted,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let acceptor = Arc::clone(&acceptor);
|
||||
tokio::spawn(async move {
|
||||
// Complete the handshake, then drop the connection. The client
|
||||
// captures the certificate as soon as the handshake finishes.
|
||||
let _ = acceptor.accept(stream).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let expected = fingerprint_of(cert_der.as_ref());
|
||||
(format!("https://{}", address), expected)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fetch_certificate_info_returns_real_certificate_data() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let info = fetch_certificate_info(&url)
|
||||
.await
|
||||
.expect("certificate info should be fetched");
|
||||
|
||||
assert_eq!(
|
||||
info.fingerprint, expected,
|
||||
"fingerprint must match the SHA-256 of the served certificate"
|
||||
);
|
||||
assert!(info.self_signed, "generated certificate is self-signed");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn capture_fingerprint_matches_served_certificate() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let fingerprint = capture_fingerprint(&url)
|
||||
.await
|
||||
.expect("fingerprint should be captured");
|
||||
|
||||
assert_eq!(fingerprint, expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn verify_server_certificate_with_correct_pin_succeeds() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let fingerprint = verify_server_certificate(&url, Some(&expected), false)
|
||||
.await
|
||||
.expect("matching pin should verify");
|
||||
|
||||
assert_eq!(fingerprint, expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn verify_server_certificate_with_wrong_pin_fails() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, _expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let error = verify_server_certificate(&url, Some(WRONG_PIN), false)
|
||||
.await
|
||||
.expect_err("mismatched pin should fail");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::CertificateError(ref message) if message.contains("fingerprint")),
|
||||
"expected CertificateError, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn verify_server_certificate_accepts_untrusted_on_mismatch() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let fingerprint = verify_server_certificate(&url, Some(WRONG_PIN), true)
|
||||
.await
|
||||
.expect("accept_untrusted should bypass the pin mismatch");
|
||||
|
||||
assert_eq!(fingerprint, expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn verify_server_certificate_without_pin_returns_fingerprint() {
|
||||
let (cert_der, key_der) = generate_self_signed();
|
||||
let (url, expected) = spawn_tls_server(cert_der, key_der).await;
|
||||
|
||||
let fingerprint = verify_server_certificate(&url, None, false)
|
||||
.await
|
||||
.expect("first use should return the fingerprint");
|
||||
|
||||
assert_eq!(fingerprint, expected);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fetch_certificate_info_rejects_non_https_urls() {
|
||||
let error = fetch_certificate_info("http://127.0.0.1:8006")
|
||||
.await
|
||||
.expect_err("http URL must be rejected");
|
||||
|
||||
assert!(
|
||||
matches!(error, Error::InvalidUrl(_)),
|
||||
"expected InvalidUrl, got: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_pin_is_case_insensitive_and_separator_agnostic() {
|
||||
let canonical = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90";
|
||||
|
||||
assert!(verify_pin(canonical, canonical));
|
||||
assert!(verify_pin(canonical, &canonical.to_lowercase()));
|
||||
assert!(verify_pin(&canonical.replace(':', ""), canonical));
|
||||
assert!(verify_pin(canonical, &canonical.replace(':', " ")));
|
||||
assert!(!verify_pin(canonical, WRONG_PIN));
|
||||
}
|
||||
Reference in New Issue
Block a user