feat: add cluster discovery, failover, VM management, and console support

This commit is contained in:
Matt
2026-08-09 23:01:51 +00:00
parent 7bb3ba48f0
commit 190fdb2573
100 changed files with 11351 additions and 1604 deletions
+213
View File
@@ -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();
}
+570
View File
@@ -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
);
}
+168
View File
@@ -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
);
}
+465
View File
@@ -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"
);
}
+448
View File
@@ -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"
);
}
+467
View File
@@ -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");
}
+289
View File
@@ -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();
}
+202
View File
@@ -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"
);
}
+489
View File
@@ -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);
}
+186
View File
@@ -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));
}