//! 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 clustri::{ 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" ); }