Files
clustri/src-tauri/tests/persistence.rs
T
Matt 039ac6f9d3 feat: add PBS datastore management and harden macOS keychain storage
Add Proxmox Backup Server datastore management: overview, datastore detail, download/prune/verify/GC dialogs, usePbs hook, backend commands, and tests.

Fix macOS keychain re-writes failing with 'item already exists': replace keyring 3 with keyring-core plus per-platform stores (macOS Keychain, Windows Credential Manager, Linux keyutils), recover by deleting the stale item and retrying once, and surface actionable messages for locked keychains.
2026-08-13 01:02:33 +00:00

493 lines
17 KiB
Rust

//! 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 clustri::{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,
server_type: "pve".to_string(),
}
}
#[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,
server_type: "pve".to_string(),
};
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,
server_type: "pve".to_string(),
};
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_calls(2);
}