Files
clustri/src-tauri/tests/api_pbs.rs
T

587 lines
21 KiB
Rust
Raw Normal View History

//! Integration tests for the Proxmox Backup Server (PBS) backend.
//!
//! These tests exercise the `pbs_*` `ConnectionManager` methods against a
//! local HTTP mock server: the `PBSAPIToken`/`PBSAuthCookie` auth headers,
//! datastore usage+config merging, the datastore/group/snapshot read
//! endpoints, the snapshot/group deletions, the verify/prune/gc task
//! launches, the binary file download, the kebab-case `worker-*` task
//! mapping, and the PBS-specific connect/login flows (which skip cluster
//! discovery).
use httpmock::prelude::*;
use clustri::{ConnectionConfig, ConnectionManager, EndpointConfig};
const TOKEN: &str = "root@pam!pbs-token";
/// Builds a `ConnectionManager` with a single token-mode PBS connection whose
/// primary endpoint points at the mock server.
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,
server_type: "pbs".to_string(),
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
(manager, dir)
}
#[tokio::test]
async fn token_auth_sends_pbs_api_token_header() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let version = manager
.pbs_get_version("conn")
.await
.expect("version should be fetched");
assert_eq!(version.version, "3.2.3");
assert_eq!(version.release, "bookworm");
assert_eq!(version.repoid, "dd6b00e2");
mock.assert();
}
#[tokio::test]
async fn password_mode_sends_pbs_auth_cookie() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/status/datastore-usage")
.header("Cookie", "PBSAuthCookie=ticket-123");
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();
let config = ConnectionConfig {
id: "conn".to_string(),
name: "conn".to_string(),
primary: EndpointConfig {
url: server.base_url(),
node: None,
token: None,
},
fallbacks: vec![],
cert_fingerprint: None,
trusted: false,
accept_untrusted: true,
status: "disconnected".to_string(),
cluster_name: None,
is_cluster: false,
auth_mode: "password".to_string(),
username: Some("root@pam".to_string()),
nodes: vec![],
cluster_id: None,
server_type: "pbs".to_string(),
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
manager
.set_session_ticket("conn", "ticket-123", "csrf")
.await
.expect("session should be injected");
// The `/admin/datastore` merge call is best-effort; with no mock for it,
// httpmock answers 404 and the call degrades to the usage-only list.
let datastores = manager
.pbs_get_datastores("conn")
.await
.expect("datastores should be fetched");
assert!(datastores.is_empty());
mock.assert();
}
#[tokio::test]
async fn get_datastores_merges_usage_and_config() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/api2/json/status/datastore-usage");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{
"store": "backup",
"backend-type": "filesystem",
"mount-status": "mounted",
"avail": 1_200_000_000_000u64,
"total": 2_000_000_000_000u64,
"used": 800_000_000_000u64,
"gc-status": {
"disk-bytes": 100,
"disk-chunks": 2,
"cache-stats": {"hits": 7, "misses": 3},
"upid": "UPID:store:00000000:00000000:00000000:gc:backup::"
}
}]
})
.to_string(),
);
});
server.mock(|when, then| {
when.method(GET).path("/api2/json/admin/datastore");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{
"store": "backup",
"comment": "Main store",
"maintenance": "offline"
}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let datastores = manager
.pbs_get_datastores("conn")
.await
.expect("datastores should be fetched");
assert_eq!(datastores.len(), 1);
let datastore = &datastores[0];
assert_eq!(datastore.store, "backup");
assert_eq!(datastore.total, Some(2_000_000_000_000));
assert_eq!(datastore.used, Some(800_000_000_000));
assert_eq!(datastore.avail, Some(1_200_000_000_000));
// Merged from /admin/datastore.
assert_eq!(datastore.comment.as_deref(), Some("Main store"));
assert_eq!(datastore.maintenance.as_deref(), Some("offline"));
// Usage-derived values survive where the config entry omits the key.
assert_eq!(datastore.backend_type.as_deref(), Some("filesystem"));
assert_eq!(datastore.mount_status.as_deref(), Some("mounted"));
// gc-status.cache-stats is hoisted onto gc_status.cache_hits/misses.
let gc = datastore.gc_status.as_ref().expect("gc status should be present");
assert_eq!(gc.disk_bytes, Some(100));
assert_eq!(gc.disk_chunks, Some(2));
assert_eq!(gc.cache_hits, Some(7));
assert_eq!(gc.cache_misses, Some(3));
assert!(gc.upid.as_deref().unwrap_or("").starts_with("UPID:"));
}
#[tokio::test]
async fn get_groups_hits_url_and_parses() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/admin/datastore/backup/groups");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{
"backup-id": "100",
"backup-type": "vm",
"backup-count": 3,
"last-backup": 1_700_000_000,
"comment": "Web server"
}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let groups = manager
.pbs_get_groups("conn", "backup")
.await
.expect("groups should be fetched");
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].backup_id, "100");
assert_eq!(groups[0].backup_type, "vm");
assert_eq!(groups[0].backup_count, Some(3));
assert_eq!(groups[0].last_backup, Some(1_700_000_000));
assert_eq!(groups[0].comment.as_deref(), Some("Web server"));
mock.assert();
}
#[tokio::test]
async fn get_snapshots_sends_backup_query_params() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/admin/datastore/backup/snapshots")
.query_param("backup-id", "100")
.query_param("backup-type", "vm");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [{
"backup-id": "100",
"backup-type": "vm",
"backup-time": 1_700_000_000,
"size": 1_500_000_000,
"protected": true,
"comment": "Full backup",
"verification": {"state": "ok", "upid": "UPID:verify::"}
}]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let snapshots = manager
.pbs_get_snapshots("conn", "backup", "100", "vm")
.await
.expect("snapshots should be fetched");
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].backup_id, "100");
assert_eq!(snapshots[0].backup_time, 1_700_000_000);
assert_eq!(snapshots[0].size, Some(1_500_000_000));
assert_eq!(snapshots[0].protected, Some(true));
assert_eq!(
snapshots[0]
.verification
.as_ref()
.and_then(|verification| verification.state.as_deref()),
Some("ok")
);
mock.assert();
}
#[tokio::test]
async fn delete_snapshot_uses_delete_verb_with_query_params() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(DELETE)
.path("/api2/json/admin/datastore/backup/snapshots")
.query_param("backup-id", "100")
.query_param("backup-type", "vm")
.query_param("backup-time", "1700000000");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":null}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
manager
.pbs_delete_snapshot("conn", "backup", "100", "vm", 1_700_000_000)
.await
.expect("snapshot should be deleted");
mock.assert();
}
#[tokio::test]
async fn run_verify_posts_store_and_returns_upid() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/admin/datastore/backup/verify")
.body_includes("store=backup");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":"UPID:store:00000000:00000000:00000000:verify:backup::"}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let upid = manager
.pbs_run_verify("conn", "backup")
.await
.expect("verify should start");
assert!(upid.starts_with("UPID:"));
assert!(upid.contains("verify"));
mock.assert();
}
#[tokio::test]
async fn run_prune_sends_only_provided_keep_fields_and_dry_run() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/api2/json/admin/datastore/backup/prune-datastore")
.body_includes("store=backup")
.body_includes("keep-last=7")
.body_includes("keep-daily=14")
.body_includes("dry-run=1")
.body_excludes("keep-weekly")
.body_excludes("keep-monthly")
.body_excludes("keep-yearly");
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":"UPID:store:00000000:00000000:00000000:prune:backup::"}"#);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let upid = manager
.pbs_run_prune("conn", "backup", Some(7), Some(14), None, None, None, true)
.await
.expect("prune should start");
assert!(upid.starts_with("UPID:"));
mock.assert_calls(1);
}
#[tokio::test]
async fn download_snapshot_file_streams_binary_to_disk() {
let server = MockServer::start();
let bytes: Vec<u8> = vec![0, 1, 2, 3, 250, 251, 252, 253, 254, 255];
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/admin/datastore/backup/download")
.query_param("backup-id", "100")
.query_param("backup-type", "vm")
.query_param("backup-time", "1700000000")
.query_param("file-name", "index.json.blob");
then.status(200).body(&bytes[..]);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let dir = tempfile::tempdir().expect("temp dir should be created");
let save_path = dir.path().join("index.json.blob");
let save_path_str = save_path.to_str().expect("path should be UTF-8");
let returned = manager
.pbs_download_snapshot_file(
"conn",
"backup",
"100",
"vm",
1_700_000_000,
"index.json.blob",
false,
save_path_str,
)
.await
.expect("download should succeed");
assert_eq!(returned, save_path_str);
let written = std::fs::read(&save_path).expect("downloaded file should exist");
assert_eq!(written, bytes);
mock.assert();
}
#[tokio::test]
async fn get_tasks_maps_pbs_worker_fields_into_task() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/nodes/localhost/tasks");
then.status(200)
.header("content-type", "application/json")
.body(
serde_json::json!({
"data": [
{
"upid": "UPID:localhost:00000000:00000000:00000000:verify:backup::root@pam:",
"node": "localhost",
"pid": 1234,
"pstart": 100,
"starttime": 1_700_000_000,
"endtime": 1_700_000_100,
"status": "ok",
"user": "root@pam",
"worker-id": "backup",
"worker-type": "verify"
},
{
"upid": "UPID:localhost:00000000:00000000:00000000:gc:backup::root@pam:",
"node": "localhost",
"pid": 5678,
"pstart": 200,
"starttime": 1_700_000_000,
"status": "running",
"user": "root@pam",
"worker-type": "gc"
}
]
})
.to_string(),
);
});
let (manager, _dir) = setup_manager(&server.base_url(), TOKEN).await;
let tasks = manager
.pbs_get_tasks("conn")
.await
.expect("tasks should be fetched");
assert_eq!(tasks.len(), 2);
// A finished task maps `status: ok` onto the PVE-style exit status.
let finished = &tasks[0];
assert_eq!(finished.r#type, "verify");
assert_eq!(finished.id, "backup");
assert_eq!(finished.node, "localhost");
assert_eq!(finished.user, "root@pam");
assert_eq!(finished.pid, 1234);
assert_eq!(finished.endtime, Some(1_700_000_100));
assert_eq!(finished.status, None);
assert_eq!(finished.exitstatus.as_deref(), Some("OK"));
// A running task keeps `status: running` and has no exit status.
let running = &tasks[1];
assert_eq!(running.r#type, "gc");
assert_eq!(running.status.as_deref(), Some("running"));
assert_eq!(running.exitstatus, None);
mock.assert();
}
#[tokio::test]
async fn connect_for_pbs_skips_node_discovery() {
let server = MockServer::start();
let version_mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
});
// No `/nodes` or `/cluster/status` mocks are registered: a PVE-style
// discovery pass would hit httpmock's 404 and fail the connect.
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: 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,
server_type: "pbs".to_string(),
};
manager
.add_connection(config, &path)
.await
.expect("connection should be added");
let result = manager
.connect("conn", &path)
.await
.expect("PBS connect should succeed with only /version mocked");
assert_eq!(result.status, "connected");
assert_eq!(result.merged_into, None);
assert_eq!(result.connection_id, "conn");
version_mock.assert_calls(1);
}
#[tokio::test]
async fn login_with_token_validates_against_pbs_version_endpoint() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/api2/json/version")
.header("Authorization", format!("PBSAPIToken={}", TOKEN));
then.status(200)
.header("content-type", "application/json")
.body(r#"{"data":{"version":"3.2.3","release":"bookworm","repoid":"dd6b00e2"}}"#);
});
let manager = ConnectionManager::new();
let result = manager
.login_with_token(&server.base_url(), TOKEN, "pbs")
.await
.expect("login should validate the token against /version");
assert!(!result.connection_id.is_empty());
assert_eq!(result.ticket, TOKEN);
mock.assert();
}
#[test]
fn public_structs_serialize_to_camel_case_shapes() {
// The frontend types (`src/types/pbs.ts`) mirror the backend structs'
// camelCase serialization; these assertions guard that contract for every
// struct that deserializes from kebab-case raw JSON.
let snapshot = clustri::PbsSnapshot {
backup_id: "100".to_string(),
backup_type: "vm".to_string(),
backup_time: 1_700_000_000,
size: Some(1_500_000_000),
protected: Some(true),
comment: None,
files: Some(vec!["index.json.blob".to_string()]),
fingerprint: None,
owner: None,
verification: None,
};
let json = serde_json::to_value(&snapshot).expect("snapshot should serialize");
assert_eq!(json["backupId"], "100");
assert_eq!(json["backupType"], "vm");
assert_eq!(json["backupTime"], 1_700_000_000);
assert_eq!(json["size"], 1_500_000_000);
assert_eq!(json["protected"], true);
assert_eq!(json["files"][0], "index.json.blob");
let job = clustri::PbsJob {
id: "verify-1".to_string(),
store: Some("backup".to_string()),
schedule: None,
comment: None,
disable: None,
last_run_state: Some("OK".to_string()),
last_run_endtime: Some(1_700_000_000),
next_run: None,
keep_last: Some(7),
keep_daily: None,
keep_weekly: None,
keep_monthly: None,
keep_yearly: None,
ignore_verified: None,
max_depth: Some(5),
};
let json = serde_json::to_value(&job).expect("job should serialize");
assert_eq!(json["lastRunState"], "OK");
assert_eq!(json["lastRunEndtime"], 1_700_000_000);
assert_eq!(json["keepLast"], 7);
assert_eq!(json["maxDepth"], 5);
// Deserialize from the raw kebab-case wire shape, then verify the
// serialized shape is camelCase (the same round-trip the API requests go
// through).
let node_status: clustri::PbsNodeStatus = serde_json::from_value(serde_json::json!({
"cpu": 0.15,
"current-kernel": {"machine": "x86_64", "release": "6.8.12-4-pve", "sysname": "Linux"}
}))
.expect("node status should deserialize from kebab-case keys");
let json = serde_json::to_value(&node_status).expect("node status should serialize");
assert_eq!(json["cpu"], 0.15);
assert_eq!(json["currentKernel"]["release"], "6.8.12-4-pve");
}