mirror of
https://github.com/GoodOlClint/PSProxmoxVE.git
synced 2026-09-03 18:55:33 +00:00
8a82146acc
* fix: allocate a real VMID for Copy-PveVm/Copy-PveContainer and honor -Storage
Copy-PveVm and Copy-PveContainer defaulted newid to 0 when -NewVmId
was omitted, and never sent -Storage on the clone request even
though both cmdlets declare it. Both cmdlets now allocate via
ClusterConfigService.GetNextId when -NewVmId is null, and forward
-Storage into the clone form body. Since PVE rejects storage on a
linked clone, -Storage without -Full now fails fast client-side
instead of failing later against the API.
NewPveVmCmdlet and ImportPveOvaCmdlet hand-rolled the same
GET cluster/nextid call with a manual JObject parse; both now go
through ClusterConfigService.GetNextId so a response without a
data field raises the service's diagnosable InvalidOperationException
rather than a NullReferenceException.
Closes #135
* test: pin CloneVm/CloneContainer storage and newid form-body behavior
Offline xUnit coverage per ADR 0021: storage present in the clone
form body when supplied, absent when omitted, and newid forwarded
verbatim (never coerced to 0) by the service layer.
* fix: add missing storage parameter to VmService.CloneVm
VmService.cs was omitted from the earlier push; this restores the
storage parameter and form-body wiring that belongs with this fix.
* fix: drop the client-side -Storage/-Full guard from the Copy cmdlets
PVE returns the same error itself when storage is sent on a linked
clone, so the guard only saved one round trip, had no test, and rested
on a behaviour claim the OpenAPI spec does not document.
* test: cover the -Storage/-Full guard in Copy-PveVm and Copy-PveContainer
Offline Pester coverage for the client-side StorageRequiresFullClone
guard: -Storage without -Full throws before a session is required,
and -Storage with -Full does not trip the check.
* test: revert the Pester cases for the removed -Storage/-Full guard
The guard was dropped in bfe2483, so these cases assert an error the
cmdlets no longer raise.
---------
Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
273 lines
12 KiB
C#
273 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Moq;
|
|
using PSProxmoxVE.Core.Authentication;
|
|
using PSProxmoxVE.Core.Client;
|
|
using PSProxmoxVE.Core.Services;
|
|
using Xunit;
|
|
|
|
namespace PSProxmoxVE.Core.Tests.Services
|
|
{
|
|
public class VmServiceTests
|
|
{
|
|
private const string TestNode = "pve1";
|
|
private const int TestVmId = 100;
|
|
|
|
private static PveSession CreateSession() =>
|
|
new PveSession("pve1.example.com", 8006, true, "PVE:root@pam:TEST_TOKEN");
|
|
|
|
[Fact]
|
|
public void ExecuteGuestCommand_SendsCommandAndArgsAsRepeatedCommandArray()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<IEnumerable<KeyValuePair<string, string>>>()))
|
|
.Callback<string, IEnumerable<KeyValuePair<string, string>>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":{\"pid\":4242}}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
var pid = service.ExecuteGuestCommand(CreateSession(), TestNode, TestVmId,
|
|
"cmd.exe", new[] { "/c", "echo", "WLMARK42" });
|
|
|
|
Assert.Equal(4242, pid);
|
|
Assert.NotNull(captured);
|
|
|
|
// Every element (exe + each arg) is its own "command" entry, in order.
|
|
Assert.All(captured!, kvp => Assert.Equal("command", kvp.Key));
|
|
Assert.Equal(
|
|
new[] { "cmd.exe", "/c", "echo", "WLMARK42" },
|
|
captured!.Select(kvp => kvp.Value).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecuteGuestCommand_DoesNotUseInputDataForArgs()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<IEnumerable<KeyValuePair<string, string>>>()))
|
|
.Callback<string, IEnumerable<KeyValuePair<string, string>>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":{\"pid\":1}}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.ExecuteGuestCommand(CreateSession(), TestNode, TestVmId,
|
|
"powershell.exe", new[] { "-NoProfile", "-Command", "echo hi" });
|
|
|
|
Assert.NotNull(captured);
|
|
// Args are argv, not STDIN — "input-data" must never be emitted.
|
|
Assert.DoesNotContain(captured!, kvp => kvp.Key == "input-data");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecuteGuestCommand_NoArgs_SendsSingleCommandEntry()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<IEnumerable<KeyValuePair<string, string>>>()))
|
|
.Callback<string, IEnumerable<KeyValuePair<string, string>>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":{\"pid\":7}}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.ExecuteGuestCommand(CreateSession(), TestNode, TestVmId, "whoami", null);
|
|
|
|
Assert.NotNull(captured);
|
|
var only = Assert.Single(captured!);
|
|
Assert.Equal("command", only.Key);
|
|
Assert.Equal("whoami", only.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecuteGuestCommand_EmptyArgs_SendsSingleCommandEntry()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<IEnumerable<KeyValuePair<string, string>>>()))
|
|
.Callback<string, IEnumerable<KeyValuePair<string, string>>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":{\"pid\":9}}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.ExecuteGuestCommand(CreateSession(), TestNode, TestVmId, "whoami", new string[0]);
|
|
|
|
Assert.NotNull(captured);
|
|
var only = Assert.Single(captured!);
|
|
Assert.Equal("command", only.Key);
|
|
Assert.Equal("whoami", only.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExecuteGuestCommand_NullArgElement_ThrowsArgumentException()
|
|
{
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
var service = new VmService(mockClient.Object);
|
|
|
|
var ex = Assert.Throws<ArgumentException>(() =>
|
|
service.ExecuteGuestCommand(CreateSession(), TestNode, TestVmId,
|
|
"cmd.exe", new[] { "/c", null!, "echo" }));
|
|
Assert.Equal("args", ex.ParamName);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebootVm_PostsToTheNativeRebootEndpoint()
|
|
{
|
|
string? resource = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((r, _) => resource = r)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmreboot:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
var task = service.RebootVm(CreateSession(), TestNode, TestVmId);
|
|
|
|
// Composing a reboot as shutdown + start races PVE's post-stop cleanup for the
|
|
// config lock; the native endpoint keeps the whole restart server-side.
|
|
Assert.Equal($"nodes/{TestNode}/qemu/{TestVmId}/status/reboot", resource);
|
|
Assert.Contains("qmreboot", task.Upid);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebootVm_SendsTimeoutWhenSupplied()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmreboot:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.RebootVm(CreateSession(), TestNode, TestVmId, 45);
|
|
|
|
Assert.NotNull(captured);
|
|
Assert.Single(captured!);
|
|
Assert.Equal("timeout", captured![0].Key);
|
|
Assert.Equal("45", captured![0].Value);
|
|
}
|
|
|
|
[Fact]
|
|
public void RebootVm_OmitsTimeoutWhenNotSupplied()
|
|
{
|
|
List<KeyValuePair<string, string>>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((_, data) => captured = data.ToList())
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmreboot:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.RebootVm(CreateSession(), TestNode, TestVmId);
|
|
|
|
Assert.NotNull(captured);
|
|
Assert.Empty(captured!);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveVm_WithSkipLockTrue_IncludesSkiplockInQueryString()
|
|
{
|
|
string? resource = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.DeleteAsync(It.IsAny<string>()))
|
|
.Callback<string>(r => resource = r)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmremove:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.RemoveVm(CreateSession(), TestNode, TestVmId, purge: false, skipLock: true);
|
|
|
|
Assert.NotNull(resource);
|
|
Assert.Contains("skiplock=1", resource!);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveVm_WithSkipLockFalse_OmitsSkiplockFromQueryString()
|
|
{
|
|
string? resource = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.DeleteAsync(It.IsAny<string>()))
|
|
.Callback<string>(r => resource = r)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmremove:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.RemoveVm(CreateSession(), TestNode, TestVmId, purge: false, skipLock: false);
|
|
|
|
Assert.NotNull(resource);
|
|
Assert.DoesNotContain("skiplock", resource!);
|
|
}
|
|
|
|
[Fact]
|
|
public void RemoveVm_WithPurgeAndSkipLock_IncludesBothInQueryString()
|
|
{
|
|
string? resource = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.DeleteAsync(It.IsAny<string>()))
|
|
.Callback<string>(r => resource = r)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmremove:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.RemoveVm(CreateSession(), TestNode, TestVmId, purge: true, skipLock: true);
|
|
|
|
Assert.NotNull(resource);
|
|
Assert.Contains("purge=1", resource!);
|
|
Assert.Contains("skiplock=1", resource!);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloneVm_WithStorage_IncludesStorageInFormBody()
|
|
{
|
|
Dictionary<string, string>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((_, data) => captured = data)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmclone:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.CloneVm(CreateSession(), TestNode, TestVmId, 200, storage: "local-zfs");
|
|
|
|
Assert.NotNull(captured);
|
|
Assert.Equal("local-zfs", captured!["storage"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void CloneVm_WithoutStorage_OmitsStorageFromFormBody()
|
|
{
|
|
Dictionary<string, string>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((_, data) => captured = data)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmclone:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.CloneVm(CreateSession(), TestNode, TestVmId, 200);
|
|
|
|
Assert.NotNull(captured);
|
|
Assert.False(captured!.ContainsKey("storage"));
|
|
}
|
|
|
|
[Fact]
|
|
public void CloneVm_SendsAllocatedNewidNeverZero()
|
|
{
|
|
Dictionary<string, string>? captured = null;
|
|
var mockClient = new Mock<IPveHttpClient>();
|
|
mockClient
|
|
.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
|
|
.Callback<string, Dictionary<string, string>>((_, data) => captured = data)
|
|
.ReturnsAsync("{\"data\":\"UPID:pve1:00001234:00005678:6A970AAB:qmclone:100:root@pam:\"}");
|
|
|
|
var service = new VmService(mockClient.Object);
|
|
service.CloneVm(CreateSession(), TestNode, TestVmId, 305);
|
|
|
|
Assert.NotNull(captured);
|
|
Assert.Equal("305", captured!["newid"]);
|
|
}
|
|
|
|
}
|
|
}
|