using System; using System.Collections.Generic; using System.Threading.Tasks; using Moq; using Xunit; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Core.Tests.Services { public class StorageServiceTests { private sealed class CapturedPost { public int Calls { get; set; } public string? Path { get; set; } public Dictionary? Form { get; set; } } private static CapturedPost CapturePost(Mock mockClient, string json) { var captured = new CapturedPost(); mockClient.Setup(c => c.PostAsync(It.IsAny(), It.IsAny>())) .Callback?>((path, form) => { captured.Calls++; captured.Path = path; captured.Form = form; }) .ReturnsAsync(json); return captured; } private sealed class CapturedUpload { public int Calls { get; set; } public string? Path { get; set; } public Dictionary? Fields { get; set; } } private static CapturedUpload CaptureUpload(Mock mockClient, string json) { var captured = new CapturedUpload(); mockClient.Setup(c => c.UploadFileAsync( It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny>())) .Callback?, string?, string?, Action?>( (path, _, fields, _, _, _) => { captured.Calls++; captured.Path = path; captured.Fields = fields; }) .ReturnsAsync(json); return captured; } /// Test seam that records the timeout override the base class receives for a call. private sealed class TimeoutCapturingStorageService : StorageService { private readonly IPveHttpClient _client; public TimeSpan? SeenTimeout; public TimeoutCapturingStorageService(IPveHttpClient client) { _client = client; } internal override IPveHttpClient CreateClient(PveSession session, TimeSpan? timeoutOverride) { SeenTimeout = timeoutOverride; return _client; } } private readonly Mock _mockClient; private readonly StorageService _service; private readonly PveSession _session; public StorageServiceTests() { _mockClient = new Mock(); _service = new StorageService(_mockClient.Object); _session = new PveSession( "pve.example.com", 8006, skipCertificateCheck: true, apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); } // ----------------------------------------------------------------- // GetStorages // ----------------------------------------------------------------- [Fact] public void GetStorages_ClusterWide_ReturnsStorageArray() { // Arrange _mockClient.Setup(c => c.GetAsync("storage")) .ReturnsAsync(@"{""data"":[ {""storage"":""local"",""type"":""dir"",""content"":""images,iso,vztmpl"",""total"":107374182400,""used"":53687091200,""avail"":53687091200,""enabled"":1,""shared"":0,""active"":1}, {""storage"":""local-lvm"",""type"":""lvmthin"",""content"":""images,rootdir"",""total"":214748364800,""used"":107374182400,""avail"":107374182400,""enabled"":1,""shared"":0,""active"":1} ]}"); // Act var result = _service.GetStorages(_session); // Assert Assert.Equal(2, result.Length); Assert.Equal("local", result[0].Storage); Assert.Equal("dir", result[0].Type); Assert.Equal(107374182400L, result[0].Total); Assert.Equal("local-lvm", result[1].Storage); Assert.Equal("lvmthin", result[1].Type); _mockClient.Verify(c => c.GetAsync("storage"), Times.Once); } [Fact] public void GetStorages_ByNode_QueriesNodeEndpoint() { // Arrange _mockClient.Setup(c => c.GetAsync("nodes/pve1/storage")) .ReturnsAsync(@"{""data"":[{""storage"":""ceph-pool"",""type"":""rbd"",""content"":""images"",""enabled"":1,""shared"":1,""active"":1}]}"); // Act var result = _service.GetStorages(_session, "pve1"); // Assert Assert.Single(result); Assert.Equal("ceph-pool", result[0].Storage); Assert.Equal("rbd", result[0].Type); _mockClient.Verify(c => c.GetAsync("nodes/pve1/storage"), Times.Once); } [Fact] public void GetStorages_EmptyData_ReturnsEmptyArray() { // Arrange _mockClient.Setup(c => c.GetAsync("storage")) .ReturnsAsync(@"{""data"":[]}"); // Act var result = _service.GetStorages(_session); // Assert Assert.Empty(result); } // ----------------------------------------------------------------- // GetStorageContent // ----------------------------------------------------------------- [Fact] public void GetStorageContent_ReturnsContentArray() { // Arrange _mockClient.Setup(c => c.GetAsync("nodes/pve1/storage/local/content")) .ReturnsAsync(@"{""data"":[ {""volid"":""local:iso/debian-12.iso"",""content"":""iso"",""format"":""iso"",""size"":3909091328,""ctime"":1700000000}, {""volid"":""local:vztmpl/ubuntu-22.04-standard.tar.zst"",""content"":""vztmpl"",""format"":""tgz"",""size"":131072000,""ctime"":1700100000} ]}"); // Act var result = _service.GetStorageContent(_session, "pve1", "local"); // Assert Assert.Equal(2, result.Length); Assert.Equal("local:iso/debian-12.iso", result[0].VolId); Assert.Equal("iso", result[0].Content); Assert.Equal(3909091328L, result[0].Size); Assert.Equal("local:vztmpl/ubuntu-22.04-standard.tar.zst", result[1].VolId); } [Fact] public void GetStorageContent_WithContentTypeFilter_AppendsQueryParam() { // Arrange _mockClient.Setup(c => c.GetAsync("nodes/pve1/storage/local/content?content=iso")) .ReturnsAsync(@"{""data"":[{""volid"":""local:iso/debian-12.iso"",""content"":""iso"",""format"":""iso"",""size"":3909091328}]}"); // Act var result = _service.GetStorageContent(_session, "pve1", "local", "iso"); // Assert Assert.Single(result); Assert.Equal("iso", result[0].Content); _mockClient.Verify(c => c.GetAsync("nodes/pve1/storage/local/content?content=iso"), Times.Once); } // ----------------------------------------------------------------- // CreateStorage // ----------------------------------------------------------------- [Fact] public void CreateStorage_ReturnsCreatedStorage() { // Arrange var config = new Dictionary { ["storage"] = "nfs-backup", ["type"] = "nfs", ["server"] = "192.168.1.10", ["export"] = "/mnt/backup", ["content"] = "backup" }; _mockClient.Setup(c => c.PostAsync("storage", It.IsAny>())) .ReturnsAsync(@"{""data"":{""storage"":""nfs-backup"",""type"":""nfs"",""content"":""backup"",""enabled"":1,""shared"":1}}"); // Act var result = _service.CreateStorage(_session, config); // Assert Assert.Equal("nfs-backup", result.Storage); Assert.Equal("nfs", result.Type); Assert.Equal("backup", result.Content); _mockClient.Verify(c => c.PostAsync("storage", It.IsAny>()), Times.Once); } [Fact] public void CreateStorage_SendsExactForm() { // Arrange var captured = CapturePost(_mockClient, @"{""data"":{""storage"":""local"",""type"":""dir""}}"); var config = new Dictionary { ["storage"] = "local", ["type"] = "dir", ["shared"] = "1" }; // Act _service.CreateStorage(_session, config); // Assert Assert.Equal(1, captured.Calls); Assert.Equal("storage", captured.Path); Assert.NotNull(captured.Form); Assert.Equal("local", captured.Form!["storage"]); Assert.Equal("dir", captured.Form["type"]); Assert.Equal("1", captured.Form["shared"]); Assert.Equal(3, captured.Form.Count); } [Fact] public void CreateStorage_NonStringValues_AreStringified() { // Arrange var captured = CapturePost(_mockClient, @"{""data"":{""storage"":""local"",""type"":""dir""}}"); var config = new Dictionary { ["storage"] = "local", ["shared"] = 1, ["disable"] = true, ["notes"] = null! }; // Act _service.CreateStorage(_session, config); // Assert Assert.NotNull(captured.Form); Assert.Equal("1", captured.Form!["shared"]); Assert.Equal("True", captured.Form["disable"]); Assert.Equal(string.Empty, captured.Form["notes"]); } [Fact] public void CreateStorage_EmptyResponseBody_ReturnsEmptyStorage() { // Arrange CapturePost(_mockClient, string.Empty); // Act var result = _service.CreateStorage(_session, new Dictionary { ["storage"] = "local" }); // Assert Assert.Equal(string.Empty, result.Storage); } [Fact] public void CreateStorage_NullConfig_ThrowsArgumentNullException() { Assert.Throws(() => _service.CreateStorage(_session, null!)); } // ----------------------------------------------------------------- // UpdateStorage // ----------------------------------------------------------------- [Fact] public void UpdateStorage_CallsPutWithCorrectPath() { // Arrange var config = new Dictionary { ["content"] = "backup,images" }; _mockClient.Setup(c => c.PutAsync("storage/nfs-backup", config)) .ReturnsAsync(@"{""data"":null}"); // Act _service.UpdateStorage(_session, "nfs-backup", config); // Assert _mockClient.Verify(c => c.PutAsync("storage/nfs-backup", config), Times.Once); } [Fact] public void UpdateStorage_NullConfig_ThrowsArgumentNullException() { Assert.Throws(() => _service.UpdateStorage(_session, "local", null!)); } // ----------------------------------------------------------------- // RemoveStorage // ----------------------------------------------------------------- [Fact] public void RemoveStorage_CallsDeleteWithCorrectPath() { // Arrange _mockClient.Setup(c => c.DeleteAsync("storage/nfs-backup")) .ReturnsAsync(@"{""data"":null}"); // Act _service.RemoveStorage(_session, "nfs-backup"); // Assert _mockClient.Verify(c => c.DeleteAsync("storage/nfs-backup"), Times.Once); } [Fact] public void RemoveStorage_EscapesPathTraversalInName() { // Arrange _mockClient.Setup(c => c.DeleteAsync("storage/..%2Faccess%2Fusers%2Fx")) .ReturnsAsync(@"{""data"":null}"); // Act _service.RemoveStorage(_session, "../access/users/x"); // Assert _mockClient.Verify(c => c.DeleteAsync("storage/..%2Faccess%2Fusers%2Fx"), Times.Once); } // ----------------------------------------------------------------- // GetStorageStatus // ----------------------------------------------------------------- [Fact] public void GetStorageStatus_ReturnsStatus() { // Arrange _mockClient.Setup(c => c.GetAsync("nodes/pve1/storage/local/status")) .ReturnsAsync(@"{""data"":{""total"":107374182400,""used"":53687091200,""avail"":53687091200,""active"":1,""enabled"":1,""shared"":0,""type"":""dir"",""content"":""images,iso""}}"); // Act var result = _service.GetStorageStatus(_session, "pve1", "local"); // Assert Assert.Equal(107374182400L, result.Total); Assert.Equal(53687091200L, result.Used); Assert.Equal(53687091200L, result.Available); Assert.Equal(1, result.Active); Assert.Equal("dir", result.Type); } // ----------------------------------------------------------------- // RemoveContent // ----------------------------------------------------------------- [Fact] public void RemoveContent_CallsDeleteWithCorrectPath() { // Arrange _mockClient.Setup(c => c.DeleteAsync("nodes/pve1/storage/local/content/local%3Aiso%2Fdebian-12.iso")) .ReturnsAsync(@"{""data"":null}"); // Act _service.RemoveContent(_session, "pve1", "local", "local:iso/debian-12.iso"); // Assert _mockClient.Verify(c => c.DeleteAsync("nodes/pve1/storage/local/content/local%3Aiso%2Fdebian-12.iso"), Times.Once); } // ----------------------------------------------------------------- // UpdateContent // ----------------------------------------------------------------- [Fact] public void UpdateContent_CallsPutWithCorrectPath() { // Arrange var config = new Dictionary { ["notes"] = "Weekly backup" }; _mockClient.Setup(c => c.PutAsync( "nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100.vma.zst", config)) .ReturnsAsync(@"{""data"":null}"); // Act _service.UpdateContent(_session, "pve1", "local", "local:backup/vzdump-qemu-100.vma.zst", config); // Assert _mockClient.Verify(c => c.PutAsync( "nodes/pve1/storage/local/content/local%3Abackup%2Fvzdump-qemu-100.vma.zst", config), Times.Once); } [Fact] public void UpdateContent_NullConfig_ThrowsArgumentNullException() { Assert.Throws(() => _service.UpdateContent(_session, "pve1", "local", "vol", null!)); } // ----------------------------------------------------------------- // UploadIso // ----------------------------------------------------------------- [Fact] public void UploadIso_ReturnsTaskWithUpid() { // Arrange _mockClient.Setup(c => c.UploadFileAsync( "nodes/pve1/storage/local/upload", "/tmp/debian-12.iso", It.IsAny>(), null, null, null)) .ReturnsAsync(@"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); // Act var result = _service.UploadIso(_session, "pve1", "local", "/tmp/debian-12.iso"); // Assert Assert.Contains("UPID:pve1", result.Upid); Assert.Equal("pve1", result.Node); Assert.Equal("running", result.Status); } [Fact] public void UploadIso_DefaultContentType_SendsIso() { // Arrange var captured = CaptureUpload(_mockClient, @"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); // Act _service.UploadIso(_session, "pve1", "local", "/tmp/debian-12.iso"); // Assert Assert.Equal(1, captured.Calls); Assert.Equal("nodes/pve1/storage/local/upload", captured.Path); Assert.NotNull(captured.Fields); Assert.Equal("iso", captured.Fields!["content"]); Assert.Single(captured.Fields); } [Fact] public void UploadIso_ExplicitContentType_SendsIt() { // Arrange var captured = CaptureUpload(_mockClient, @"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); // Act _service.UploadIso(_session, "pve1", "local", "/tmp/rootfs.tar.zst", contentType: "vztmpl"); // Assert Assert.Equal(1, captured.Calls); Assert.NotNull(captured.Fields); Assert.Equal("vztmpl", captured.Fields!["content"]); Assert.Single(captured.Fields); } [Fact] public void UploadIso_EscapesNodeAndStorageInPath() { // Arrange var captured = CaptureUpload(_mockClient, @"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); // Act _service.UploadIso(_session, "pve node", "local storage", "/tmp/debian-12.iso"); // Assert Assert.Equal("nodes/pve%20node/storage/local%20storage/upload", captured.Path); } [Fact] public void UploadIso_TimeoutOverride_PassesItToClientConstruction() { // Arrange var captured = CaptureUpload(_mockClient, @"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); var service = new TimeoutCapturingStorageService(_mockClient.Object); // Act service.UploadIso(_session, "pve1", "local", "/tmp/debian-12.iso", timeout: TimeSpan.FromMinutes(45)); // Assert Assert.Equal(TimeSpan.FromMinutes(45), service.SeenTimeout); } [Fact] public void UploadIso_NoTimeout_DefaultsToThirtyMinutes() { // Arrange CaptureUpload(_mockClient, @"{""data"":""UPID:pve1:000AAA:00000001:65F00000:upload:local:root@pam:""}"); var service = new TimeoutCapturingStorageService(_mockClient.Object); // Act service.UploadIso(_session, "pve1", "local", "/tmp/debian-12.iso"); // Assert Assert.Equal(TimeSpan.FromMinutes(30), service.SeenTimeout); } // ----------------------------------------------------------------- // DownloadUrl // ----------------------------------------------------------------- [Fact] public void DownloadUrl_ReturnsTaskWithUpid() { // Arrange _mockClient.Setup(c => c.PostAsync( "nodes/pve1/storage/local/download-url", It.IsAny>())) .ReturnsAsync(@"{""data"":""UPID:pve1:000BBB:00000002:65F00001:download:local:root@pam:""}"); // Act var result = _service.DownloadUrl( _session, "pve1", "local", "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "noble-server-cloudimg-amd64.img", "iso"); // Assert Assert.Contains("UPID:pve1", result.Upid); Assert.Equal("pve1", result.Node); Assert.Equal("running", result.Status); } [Fact] public void DownloadUrl_SendsExactForm() { // Arrange var captured = CapturePost(_mockClient, @"{""data"":""UPID:pve1:000BBB:00000002:65F00001:download:local:root@pam:""}"); // Act _service.DownloadUrl( _session, "pve1", "local", "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", "noble-server-cloudimg-amd64.img", "iso"); // Assert Assert.Equal(1, captured.Calls); Assert.Equal("nodes/pve1/storage/local/download-url", captured.Path); Assert.NotNull(captured.Form); Assert.Equal("https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", captured.Form!["url"]); Assert.Equal("noble-server-cloudimg-amd64.img", captured.Form["filename"]); Assert.Equal("iso", captured.Form["content"]); Assert.Equal(3, captured.Form.Count); } [Fact] public void DownloadUrl_EscapesNodeAndStorageInPath() { // Arrange var captured = CapturePost(_mockClient, @"{""data"":""UPID:pve1:000BBB:00000002:65F00001:download:local:root@pam:""}"); // Act _service.DownloadUrl(_session, "pve node", "local storage", "https://example.com/f.iso", "f.iso", "iso"); // Assert Assert.Equal(1, captured.Calls); Assert.Equal("nodes/pve%20node/storage/local%20storage/download-url", captured.Path); } [Fact] public void DownloadUrl_TimeoutOverride_PassesItToClientConstruction() { // Arrange var captured = CapturePost(_mockClient, @"{""data"":""UPID:pve1:000BBB:00000002:65F00001:download:local:root@pam:""}"); var service = new TimeoutCapturingStorageService(_mockClient.Object); // Act service.DownloadUrl(_session, "pve1", "local", "https://example.com/f.iso", "f.iso", "iso", timeout: TimeSpan.FromMinutes(45)); // Assert Assert.Equal(TimeSpan.FromMinutes(45), service.SeenTimeout); Assert.Equal(1, captured.Calls); } [Fact] public void DownloadUrl_NoTimeout_DefaultsToThirtyMinutes() { // Arrange CapturePost(_mockClient, @"{""data"":""UPID:pve1:000BBB:00000002:65F00001:download:local:root@pam:""}"); var service = new TimeoutCapturingStorageService(_mockClient.Object); // Act service.DownloadUrl(_session, "pve1", "local", "https://example.com/f.iso", "f.iso", "iso"); // Assert Assert.Equal(TimeSpan.FromMinutes(30), service.SeenTimeout); } // ----------------------------------------------------------------- // AllocateDisk // ----------------------------------------------------------------- [Fact] public void AllocateDisk_ReturnsTaskWithUpid() { // Arrange var config = new Dictionary { ["filename"] = "vm-200-disk-0", ["size"] = "32G", ["format"] = "qcow2" }; _mockClient.Setup(c => c.PostAsync( "nodes/pve1/storage/local-lvm/content", config)) .ReturnsAsync(@"{""data"":""UPID:pve1:000CCC:00000003:65F00002:alloc:local-lvm:root@pam:""}"); // Act var result = _service.AllocateDisk(_session, "pve1", "local-lvm", config); // Assert Assert.Contains("UPID:pve1", result.Upid); Assert.Equal("pve1", result.Node); } [Fact] public void AllocateDisk_NullConfig_ThrowsArgumentNullException() { Assert.Throws(() => _service.AllocateDisk(_session, "pve1", "local", null!)); } } }