Files
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

255 lines
6.9 KiB
Rust

//! 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 clustri::{api_request, AuthContext, AuthMode, Error, ServerType};
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,
server_type: ServerType::Pve,
}
}
fn password_auth() -> AuthContext {
AuthContext {
mode: AuthMode::Password,
token: None,
ticket: Some(TICKET.to_string()),
csrf_token: Some(CSRF_TOKEN.to_string()),
server_type: ServerType::Pve,
}
}
#[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#"{"errors":"specific failure","message":"boom"}"#);
});
let error = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("request should fail");
// A string `errors` field takes precedence over the generic `message`.
assert!(
matches!(error, Error::ApiError(ref message) if message == "specific failure"),
"expected ApiError with message 'specific failure', got: {}",
error
);
mock.assert();
}
#[tokio::test]
async fn server_error_errors_object_first_pair_is_surfaced() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/api2/json/version");
then.status(400)
.header("content-type", "application/json")
.body(
r#"{"errors":{"limit":"property is not defined in schema"},"message":"Parameter verification failed."}"#,
);
});
let error = api_request(
&Client::new(),
&server.base_url(),
RMethod::GET,
"/version",
&token_auth(),
&[],
None,
)
.await
.expect_err("request should fail");
// An `errors` object surfaces its first `key: value` pair, not the generic
// `message` fallback.
assert!(
matches!(
error,
Error::ApiError(ref message)
if message == "limit: property is not defined in schema"
),
"expected ApiError with 'limit: property is not defined in schema', 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();
}