587 lines
21 KiB
Rust
587 lines
21 KiB
Rust
//! 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");
|
||
|
|
}
|