Files
PSProxmoxVE/tests/PSProxmoxVE.Core.Tests/Services/VmServiceTests.cs
T
goodolclint-claude[bot] e942878aa2 fix: plumb skiplock/force parameters through Remove-PveVm/Container (#164)
* fix: plumb skiplock parameter through Remove-PveVm and Remove-PveContainer

When -Force is specified, both cmdlets now pass skiplock=1 to PVE, which bypasses locks.
PVE honours the skiplock parameter for root@pam only.

Also updated help text on both cmdlets to clarify the limitation and behaviour.

Fixes #136.

* fix: plumb skiplock parameter for VMs, force for containers

VM removal: add skiplock=1 parameter when -Force is specified. PVE honors it
for root@pam only; non-root callers receive 403 errors. Updated help text.

Container removal: map -Force to force=1 (LXC-specific parameter for forcing
removal of running containers). Containers do not support skiplock.

Also: clarified class and parameter documentation to remove false claims about
-Force suppressing confirmation (it does not).

Fixes #136. Addresses correctness reviewer findings.

* fix: revert unrelated formatting churn, add container force test

The em-dash-to-hyphen sweep in commit 75f4bbf touched VmService.cs and
VmServiceTests.cs outside the skiplock/force change; restore the
original text there, including the <vmid> XML-doc escape that
had been un-escaped and was malforming the generated documentation.

Add ContainerServiceTests.cs, covering the untested force=1
query-string plumbing in ContainerService.RemoveContainer.

---------

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
2026-09-02 13:20:04 -05:00

222 lines
9.4 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!);
}
}
}