diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
index 4982231..84d1209 100644
--- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
+++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
@@ -99,7 +99,7 @@ namespace PSProxmoxVE.Core.Client
{
var request = BuildRequest(HttpMethod.Post, resource, mutating: true);
if (data != null)
- request.Content = new FormUrlEncodedContent(data);
+ request.Content = BuildFormContent(data);
return await SendAsync(request, resource, "POST").ConfigureAwait(false);
}
@@ -111,7 +111,7 @@ namespace PSProxmoxVE.Core.Client
{
var request = BuildRequest(HttpMethod.Put, resource, mutating: true);
if (data != null)
- request.Content = new FormUrlEncodedContent(data);
+ request.Content = BuildFormContent(data);
return await SendAsync(request, resource, "PUT").ConfigureAwait(false);
}
@@ -124,6 +124,52 @@ namespace PSProxmoxVE.Core.Client
return await SendAsync(request, resource, "DELETE").ConfigureAwait(false);
}
+ // -------------------------------------------------------------------------
+ // Form body encoding
+ // -------------------------------------------------------------------------
+
+ ///
+ /// Builds form-encoded content using minimal encoding that matches curl behavior.
+ /// .NET's over-encodes characters like
+ /// : and ! which PVE's internal API consumers (e.g. cluster join)
+ /// do not properly URL-decode.
+ ///
+ private static StringContent BuildFormContent(Dictionary data)
+ {
+ var sb = new StringBuilder();
+ foreach (var kvp in data)
+ {
+ if (sb.Length > 0) sb.Append('&');
+ sb.Append(EncodeFormValue(kvp.Key));
+ sb.Append('=');
+ sb.Append(EncodeFormValue(kvp.Value));
+ }
+ return new StringContent(sb.ToString(), Encoding.UTF8, "application/x-www-form-urlencoded");
+ }
+
+ ///
+ /// Encodes a form value with minimal percent-encoding — only characters that
+ /// would break form parsing are encoded. This matches curl's -d behavior
+ /// and avoids over-encoding that PVE does not handle correctly.
+ ///
+ private static string EncodeFormValue(string value)
+ {
+ var sb = new StringBuilder(value.Length);
+ foreach (char c in value)
+ {
+ switch (c)
+ {
+ case '&': sb.Append("%26"); break;
+ case '=': sb.Append("%3D"); break;
+ case '+': sb.Append("%2B"); break;
+ case ' ': sb.Append('+'); break;
+ case '%': sb.Append("%25"); break;
+ default: sb.Append(c); break;
+ }
+ }
+ return sb.ToString();
+ }
+
// -------------------------------------------------------------------------
// Synchronous wrappers
// -------------------------------------------------------------------------
diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs
index 22160a6..d073237 100644
--- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterConfigNodeCmdlet.cs
@@ -1,4 +1,5 @@
using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Cluster
@@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster
///
[Cmdlet(VerbsCommon.Add, "PveClusterConfigNode", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
- [OutputType(typeof(string))]
+ [OutputType(typeof(PveTask))]
public sealed class AddPveClusterConfigNodeCmdlet : PveCmdletBase
{
/// The name of the node to add.
@@ -46,6 +47,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster
[Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")]
public string[]? Links { get; set; }
+ /// Wait for the task to complete.
+ [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
+ public SwitchParameter Wait { get; set; }
+
protected override void ProcessRecord()
{
if (!ShouldProcess($"node '{Node}'", "Add to cluster configuration"))
@@ -59,7 +64,18 @@ namespace PSProxmoxVE.Cmdlets.Cluster
WriteVerbose($"Adding node '{Node}' to cluster configuration...");
var upid = service.AddConfigNode(session, Node, NewNodeIp, linkDict, NodeId, Votes,
Force.IsPresent ? true : (bool?)null, ApiVersion);
- WriteObject(upid);
+
+ var nodeName = GetNodeFromUpid(upid, session.Hostname);
+
+ var task = new PveTask { Upid = upid, Status = "running", Node = nodeName };
+
+ if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ {
+ var taskService = new TaskService();
+ task = taskService.WaitForTask(session, nodeName, upid);
+ }
+
+ WriteObject(task);
}
}
}
diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs
index 2ff938a..a8735b6 100644
--- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs
@@ -1,7 +1,12 @@
using System;
using System.Management.Automation;
+using System.Net;
using System.Runtime.InteropServices;
using System.Security;
+using System.Threading;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Exceptions;
+using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Cluster
@@ -16,7 +21,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster
///
[Cmdlet(VerbsCommon.Add, "PveClusterMember", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
- [OutputType(typeof(string))]
+ [OutputType(typeof(PveTask))]
public sealed class AddPveClusterMemberCmdlet : PveCmdletBase
{
/// Hostname or IP of an existing cluster member.
@@ -49,6 +54,13 @@ namespace PSProxmoxVE.Cmdlets.Cluster
[Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")]
public string[]? Links { get; set; }
+ /// Wait for the join task to complete.
+ [Parameter(Mandatory = false, HelpMessage = "Wait for the join task to complete before returning.")]
+ public SwitchParameter Wait { get; set; }
+
+ private const int ReauthMaxAttempts = 10;
+ private const int ReauthDelayMs = 3000;
+
protected override void ProcessRecord()
{
if (!ShouldProcess($"this node to cluster via '{Hostname}'", "Join cluster"))
@@ -68,7 +80,16 @@ namespace PSProxmoxVE.Cmdlets.Cluster
WriteVerbose($"Joining cluster via '{Hostname}'...");
var upid = service.JoinCluster(session, Hostname, Fingerprint, plainPassword,
linkDict, NodeId, Votes, Force.IsPresent ? true : (bool?)null);
- WriteObject(upid);
+
+ var node = GetNodeFromUpid(upid, session.Hostname);
+ var task = new PveTask { Upid = upid, Status = "running", Node = node };
+
+ if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ {
+ task = WaitForJoinTask(session, upid, plainPassword);
+ }
+
+ WriteObject(task);
}
finally
{
@@ -76,5 +97,55 @@ namespace PSProxmoxVE.Cmdlets.Cluster
Marshal.ZeroFreeGlobalAllocUnicode(ptr);
}
}
+
+ ///
+ /// Waits for the join task to complete. If the ticket is invalidated mid-poll
+ /// (cluster join restarts auth services), re-authenticates with retry and
+ /// continues waiting.
+ ///
+ private PveTask WaitForJoinTask(PveSession session, string upid, string plainPassword)
+ {
+ var nodeName = upid.Split(':').Length > 1 ? upid.Split(':')[1] : session.Hostname;
+ var taskService = new TaskService();
+
+ try
+ {
+ return taskService.WaitForTask(session, nodeName, upid);
+ }
+ catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ WriteVerbose("Session expired during join — waiting for auth services to restart...");
+ var newSession = ReauthenticateWithRetry(session, plainPassword);
+ return taskService.WaitForTask(newSession, nodeName, upid);
+ }
+ }
+
+ ///
+ /// Retries authentication against this node until auth services come back online
+ /// after the cluster join restarts them.
+ ///
+ private PveSession ReauthenticateWithRetry(PveSession session, string plainPassword)
+ {
+ for (int attempt = 0; attempt < ReauthMaxAttempts; attempt++)
+ {
+ Thread.Sleep(ReauthDelayMs);
+ try
+ {
+ return PveAuthenticator.AuthenticateWithCredentials(
+ session.Hostname, session.Port, session.SkipCertificateCheck,
+ "root@pam", plainPassword);
+ }
+ catch (PveApiException retryEx) when (
+ retryEx.StatusCode == HttpStatusCode.Unauthorized ||
+ retryEx.StatusCode == HttpStatusCode.ServiceUnavailable)
+ {
+ WriteVerbose($"Re-auth attempt {attempt + 1}/{ReauthMaxAttempts} failed, retrying...");
+ }
+ }
+
+ throw new InvalidOperationException(
+ "Unable to re-authenticate after cluster join. " +
+ "Auth services on this node may still be restarting.");
+ }
}
}
diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs
index f1f5be6..13621c7 100644
--- a/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/Cluster/NewPveClusterCmdlet.cs
@@ -1,4 +1,5 @@
using System.Management.Automation;
+using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Cluster
@@ -13,7 +14,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster
///
[Cmdlet(VerbsCommon.New, "PveCluster", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
- [OutputType(typeof(string))]
+ [OutputType(typeof(PveTask))]
public sealed class NewPveClusterCmdlet : PveCmdletBase
{
/// The name for the new cluster.
@@ -35,6 +36,10 @@ namespace PSProxmoxVE.Cmdlets.Cluster
[Parameter(Mandatory = false, HelpMessage = "Corosync link addresses as key=value strings (e.g. 'link0=10.0.0.1').")]
public string[]? Links { get; set; }
+ /// Wait for the cluster creation task to complete.
+ [Parameter(Mandatory = false, HelpMessage = "Wait for the task to complete before returning.")]
+ public SwitchParameter Wait { get; set; }
+
protected override void ProcessRecord()
{
if (!ShouldProcess($"cluster '{ClusterName}'", "Create new cluster"))
@@ -47,7 +52,18 @@ namespace PSProxmoxVE.Cmdlets.Cluster
WriteVerbose($"Creating cluster '{ClusterName}'...");
var upid = service.CreateCluster(session, ClusterName, linkDict, NodeId, Votes);
- WriteObject(upid);
+
+ var node = GetNodeFromUpid(upid, session.Hostname);
+
+ var task = new PveTask { Upid = upid, Status = "running", Node = node };
+
+ if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
+ {
+ var taskService = new TaskService();
+ task = taskService.WaitForTask(session, node, upid);
+ }
+
+ WriteObject(task);
}
}
}
diff --git a/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs b/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs
index bb1d1d9..4f6f181 100644
--- a/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs
+++ b/src/PSProxmoxVE/Cmdlets/HA/SetPveHaRuleCmdlet.cs
@@ -19,6 +19,10 @@ namespace PSProxmoxVE.Cmdlets.HA
HelpMessage = "HA rule ID.")]
public string Rule { get; set; } = string.Empty;
+ /// Rule type (required by PVE API on update).
+ [Parameter(Mandatory = true, Position = 1, HelpMessage = "HA rule type (as defined in PVE, e.g. node-affinity, resource-affinity, location, colocation).")]
+ public string Type { get; set; } = string.Empty;
+
/// Rule state.
[Parameter(Mandatory = false, HelpMessage = "Rule state: enabled or disabled.")]
[ValidateSet("enabled", "disabled")]
@@ -42,7 +46,7 @@ namespace PSProxmoxVE.Cmdlets.HA
var service = new HaService();
- var data = new Dictionary();
+ var data = new Dictionary { ["type"] = Type };
if (State != null) data["state"] = State;
if (Comment != null) data["comment"] = Comment;
if (Properties != null)
diff --git a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
index 2f6b775..467e57d 100644
--- a/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
+++ b/src/PSProxmoxVE/Cmdlets/PveCmdletBase.cs
@@ -160,6 +160,21 @@ namespace PSProxmoxVE.Cmdlets
TimeSpan.FromSeconds(timeoutSeconds));
}
+ ///
+ /// Extracts the node name from a UPID string (format: UPID:node:...).
+ /// Falls back to if the UPID is empty or cannot be parsed.
+ ///
+ protected static string GetNodeFromUpid(string? upid, string fallback)
+ {
+ if (upid != null && upid.Length > 0)
+ {
+ var parts = upid.Split(':');
+ if (parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]))
+ return parts[1];
+ }
+ return fallback;
+ }
+
///
/// Parses an array of Corosync link strings (e.g. "link0=10.0.0.1") into a dictionary.
/// Emits a warning for entries that do not match the expected "key=value" format.
diff --git a/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs
new file mode 100644
index 0000000..f5d65f8
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Services/ClusterConfigServiceTests.cs
@@ -0,0 +1,470 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Moq;
+using Newtonsoft.Json.Linq;
+using Xunit;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Core.Tests.Services
+{
+ public class ClusterConfigServiceTests
+ {
+ private static PveSession CreateSession()
+ {
+ return new PveSession("pve.example.com", 8006, false,
+ "root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ }
+
+ [Fact]
+ public void GetClusterConfig_ReturnsJObject()
+ {
+ // Arrange
+ var json = @"{""data"": {""nodes"": {""pve1"": {}}, ""totem"": {""version"": ""2""}}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var config = service.GetClusterConfig(CreateSession());
+
+ // Assert
+ Assert.NotNull(config);
+ Assert.NotNull(config["nodes"]);
+ Assert.NotNull(config["totem"]);
+ mockClient.Verify(c => c.GetAsync("cluster/config"), Times.Once);
+ }
+
+ [Fact]
+ public void CreateCluster_PostsClusterNameAndReturnsUpid()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/config",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var upid = service.CreateCluster(CreateSession(), "test-cluster");
+
+ // Assert
+ Assert.Contains("cluster_create", upid);
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/config",
+ It.Is>(d =>
+ d["clustername"] == "test-cluster")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void CreateCluster_WithOptionalParams_IncludesThemInPost()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:cluster_create::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/config",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+ var links = new Dictionary { ["link0"] = "10.0.0.1" };
+
+ // Act
+ service.CreateCluster(CreateSession(), "test-cluster", links, nodeid: 1, votes: 2);
+
+ // Assert
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/config",
+ It.Is>(d =>
+ d["clustername"] == "test-cluster" &&
+ d["link0"] == "10.0.0.1" &&
+ d["nodeid"] == "1" &&
+ d["votes"] == "2")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void GetConfigNodes_ReturnsNodeArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""node"": ""pve1"", ""nodeid"": 1, ""ring0_addr"": ""10.0.0.1"", ""quorum_votes"": 1},
+ {""node"": ""pve2"", ""nodeid"": 2, ""ring0_addr"": ""10.0.0.2"", ""quorum_votes"": 1}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config/nodes")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var nodes = service.GetConfigNodes(CreateSession());
+
+ // Assert
+ Assert.Equal(2, nodes.Length);
+ Assert.Equal("pve1", nodes[0].Name);
+ Assert.Equal(1, nodes[0].NodeId);
+ Assert.Equal("10.0.0.1", nodes[0].Ring0Addr);
+ Assert.Equal(1, nodes[0].QuorumVotes);
+ Assert.Equal("pve2", nodes[1].Name);
+ Assert.Equal(2, nodes[1].NodeId);
+ mockClient.Verify(c => c.GetAsync("cluster/config/nodes"), Times.Once);
+ }
+
+ [Fact]
+ public void AddConfigNode_PostsToEncodedUrlAndReturnsUpid()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:addnode::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/config/nodes/pve2",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var upid = service.AddConfigNode(CreateSession(), "pve2", newNodeIp: "10.0.0.2");
+
+ // Assert
+ Assert.Contains("addnode", upid);
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/config/nodes/pve2",
+ It.Is>(d =>
+ d["new_node_ip"] == "10.0.0.2")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void RemoveConfigNode_CallsDeleteWithEncodedUrl()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.DeleteAsync("cluster/config/nodes/pve2"))
+ .ReturnsAsync("{}");
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ service.RemoveConfigNode(CreateSession(), "pve2");
+
+ // Assert
+ mockClient.Verify(c => c.DeleteAsync("cluster/config/nodes/pve2"), Times.Once);
+ }
+
+ [Fact]
+ public void GetJoinInfo_ReturnsClusterJoinInfo()
+ {
+ // Arrange
+ var json = @"{""data"": {
+ ""config_digest"": ""abc123"",
+ ""preferred_node"": ""pve1"",
+ ""nodelist"": [{""name"": ""pve1"", ""ring0_addr"": ""10.0.0.1""}],
+ ""totem"": {""version"": ""2""}
+ }}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config/join")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var joinInfo = service.GetJoinInfo(CreateSession());
+
+ // Assert
+ Assert.Equal("abc123", joinInfo.ConfigDigest);
+ Assert.Equal("pve1", joinInfo.PreferredNode);
+ Assert.NotNull(joinInfo.Nodelist);
+ Assert.Single(joinInfo.Nodelist);
+ Assert.NotNull(joinInfo.Totem);
+ mockClient.Verify(c => c.GetAsync("cluster/config/join"), Times.Once);
+ }
+
+ [Fact]
+ public void GetJoinInfo_WithNode_AppendsQueryString()
+ {
+ // Arrange
+ var json = @"{""data"": {""config_digest"": ""abc123"", ""preferred_node"": ""pve1""}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config/join?node=pve1")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var joinInfo = service.GetJoinInfo(CreateSession(), "pve1");
+
+ // Assert
+ Assert.Equal("pve1", joinInfo.PreferredNode);
+ mockClient.Verify(c => c.GetAsync("cluster/config/join?node=pve1"), Times.Once);
+ }
+
+ [Fact]
+ public void JoinCluster_PostsRequiredParamsAndReturnsUpid()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve2:000ABC:00000001:5F1234AB:join::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/config/join",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var upid = service.JoinCluster(CreateSession(), "10.0.0.1", "AA:BB:CC:DD", "secret");
+
+ // Assert
+ Assert.Contains("join", upid);
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/config/join",
+ It.Is>(d =>
+ d["hostname"] == "10.0.0.1" &&
+ d["fingerprint"] == "AA:BB:CC:DD" &&
+ d["password"] == "secret")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void GetTotem_ReturnsJObject()
+ {
+ // Arrange
+ var json = @"{""data"": {""version"": ""2"", ""secauth"": ""on"", ""cluster_name"": ""pve-cluster""}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config/totem")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var totem = service.GetTotem(CreateSession());
+
+ // Assert
+ Assert.NotNull(totem);
+ Assert.Equal("2", totem["version"]?.ToString());
+ Assert.Equal("on", totem["secauth"]?.ToString());
+ Assert.Equal("pve-cluster", totem["cluster_name"]?.ToString());
+ mockClient.Verify(c => c.GetAsync("cluster/config/totem"), Times.Once);
+ }
+
+ [Fact]
+ public void GetApiVersion_ReturnsInt()
+ {
+ // Arrange
+ var json = @"{""data"": 10}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/config/apiversion")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var version = service.GetApiVersion(CreateSession());
+
+ // Assert
+ Assert.Equal(10, version);
+ mockClient.Verify(c => c.GetAsync("cluster/config/apiversion"), Times.Once);
+ }
+
+ [Fact]
+ public void GetClusterOptions_ReturnsPveClusterOptions()
+ {
+ // Arrange
+ var json = @"{""data"": {
+ ""keyboard"": ""en-us"",
+ ""language"": ""en"",
+ ""console"": ""html5"",
+ ""fencing"": ""watchdog"",
+ ""email_from"": ""admin@example.com"",
+ ""max_workers"": 4
+ }}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/options")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var options = service.GetClusterOptions(CreateSession());
+
+ // Assert
+ Assert.Equal("en-us", options.Keyboard);
+ Assert.Equal("en", options.Language);
+ Assert.Equal("html5", options.Console);
+ Assert.Equal("watchdog", options.Fencing);
+ Assert.Equal("admin@example.com", options.EmailFrom);
+ Assert.Equal(4, options.MaxWorkers);
+ mockClient.Verify(c => c.GetAsync("cluster/options"), Times.Once);
+ }
+
+ [Fact]
+ public void SetClusterOptions_CallsPutAsyncWithCorrectResource()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PutAsync(
+ "cluster/options",
+ It.IsAny>()))
+ .ReturnsAsync("{}");
+ var service = new ClusterConfigService(mockClient.Object);
+ var opts = new Dictionary
+ {
+ ["keyboard"] = "de",
+ ["language"] = "de"
+ };
+
+ // Act
+ service.SetClusterOptions(CreateSession(), opts);
+
+ // Assert
+ mockClient.Verify(c => c.PutAsync(
+ "cluster/options",
+ It.Is>(d =>
+ d["keyboard"] == "de" && d["language"] == "de")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void GetClusterStatus_ReturnsStatusArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""type"": ""cluster"", ""name"": ""pve-cluster"", ""nodes"": 3, ""quorate"": 1, ""version"": 5},
+ {""type"": ""node"", ""name"": ""pve1"", ""online"": 1, ""local"": 1, ""nodeid"": 1, ""ip"": ""10.0.0.1""}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/status")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var statuses = service.GetClusterStatus(CreateSession());
+
+ // Assert
+ Assert.Equal(2, statuses.Length);
+ Assert.Equal("cluster", statuses[0].Type);
+ Assert.Equal("pve-cluster", statuses[0].Name);
+ Assert.Equal(3, statuses[0].Nodes);
+ Assert.Equal(1, statuses[0].Quorate);
+ Assert.Equal("node", statuses[1].Type);
+ Assert.Equal("pve1", statuses[1].Name);
+ Assert.Equal(1, statuses[1].Online);
+ Assert.Equal("10.0.0.1", statuses[1].Ip);
+ mockClient.Verify(c => c.GetAsync("cluster/status"), Times.Once);
+ }
+
+ [Fact]
+ public void GetNextId_ReturnsInt()
+ {
+ // Arrange
+ var json = @"{""data"": ""100""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var nextId = service.GetNextId(CreateSession());
+
+ // Assert
+ Assert.Equal(100, nextId);
+ mockClient.Verify(c => c.GetAsync("cluster/nextid"), Times.Once);
+ }
+
+ [Fact]
+ public void GetNextId_WithVmid_AppendsQueryString()
+ {
+ // Arrange
+ var json = @"{""data"": ""200""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/nextid?vmid=200")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act
+ var nextId = service.GetNextId(CreateSession(), 200);
+
+ // Assert
+ Assert.Equal(200, nextId);
+ mockClient.Verify(c => c.GetAsync("cluster/nextid?vmid=200"), Times.Once);
+ }
+
+ [Fact]
+ public void GetNextId_InvalidResponse_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var json = @"{""data"": ""not-a-number""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ var ex = Assert.Throws(() => service.GetNextId(CreateSession()));
+ Assert.Contains("unexpected next VMID value", ex.Message);
+ }
+
+ [Fact]
+ public void GetNextId_NullDataField_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ var json = @"{""notdata"": ""100""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ var ex = Assert.Throws(() => service.GetNextId(CreateSession()));
+ Assert.Contains("did not contain a 'data' field", ex.Message);
+ }
+
+ [Fact]
+ public void GetClusterConfig_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetClusterConfig(null!));
+ }
+
+ [Fact]
+ public void GetConfigNodes_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetConfigNodes(null!));
+ }
+
+ [Fact]
+ public void GetClusterOptions_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetClusterOptions(null!));
+ }
+
+ [Fact]
+ public void GetNextId_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetNextId(null!));
+ }
+
+ [Fact]
+ public void GetClusterStatus_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new ClusterConfigService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetClusterStatus(null!));
+ }
+
+ [Fact]
+ public void Constructor_NullClient_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new ClusterConfigService(null!));
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs
new file mode 100644
index 0000000..3300e90
--- /dev/null
+++ b/tests/PSProxmoxVE.Core.Tests/Services/HaServiceTests.cs
@@ -0,0 +1,509 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Moq;
+using Newtonsoft.Json.Linq;
+using Xunit;
+using PSProxmoxVE.Core.Authentication;
+using PSProxmoxVE.Core.Client;
+using PSProxmoxVE.Core.Models.HA;
+using PSProxmoxVE.Core.Services;
+
+namespace PSProxmoxVE.Core.Tests.Services
+{
+ public class HaServiceTests
+ {
+ private static PveSession CreateSession()
+ {
+ return new PveSession("pve.example.com", 8006, false,
+ "root@pam!testtoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ }
+
+ // -----------------------------------------------------------------
+ // Resources
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void GetResources_ReturnsResourceArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm"", ""max_relocate"": 1, ""max_restart"": 1},
+ {""sid"": ""ct:200"", ""state"": ""stopped"", ""type"": ""ct""}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/resources")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var resources = service.GetResources(CreateSession());
+
+ // Assert
+ Assert.Equal(2, resources.Length);
+ Assert.Equal("vm:100", resources[0].Sid);
+ Assert.Equal("started", resources[0].State);
+ Assert.Equal("grp1", resources[0].Group);
+ Assert.Equal("vm", resources[0].Type);
+ Assert.Equal(1, resources[0].MaxRelocate);
+ Assert.Equal(1, resources[0].MaxRestart);
+ Assert.Equal("ct:200", resources[1].Sid);
+ Assert.Equal("stopped", resources[1].State);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/resources"), Times.Once);
+ }
+
+ [Fact]
+ public void GetResource_ReturnsSingleResource()
+ {
+ // Arrange
+ var json = @"{""data"": {""sid"": ""vm:100"", ""state"": ""started"", ""group"": ""grp1"", ""type"": ""vm""}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/resources/vm%3A100")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var resource = service.GetResource(CreateSession(), "vm:100");
+
+ // Assert
+ Assert.Equal("vm:100", resource.Sid);
+ Assert.Equal("started", resource.State);
+ Assert.Equal("grp1", resource.Group);
+ Assert.Equal("vm", resource.Type);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/resources/vm%3A100"), Times.Once);
+ }
+
+ [Fact]
+ public void GetResource_EncodesColonInSid()
+ {
+ // Arrange
+ var json = @"{""data"": {""sid"": ""ct:200"", ""state"": ""stopped""}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/resources/ct%3A200")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var resource = service.GetResource(CreateSession(), "ct:200");
+
+ // Assert
+ Assert.Equal("ct:200", resource.Sid);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/resources/ct%3A200"), Times.Once);
+ }
+
+ [Fact]
+ public void CreateResource_PostsSidAndOptions()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/ha/resources",
+ It.IsAny>()))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+ var options = new Dictionary
+ {
+ ["state"] = "started",
+ ["group"] = "grp1"
+ };
+
+ // Act
+ service.CreateResource(CreateSession(), "vm:100", options);
+
+ // Assert
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/ha/resources",
+ It.Is>(d =>
+ d["sid"] == "vm:100" &&
+ d["state"] == "started" &&
+ d["group"] == "grp1")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void UpdateResource_PutsOptionsToEncodedUrl()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PutAsync(
+ "cluster/ha/resources/vm%3A100",
+ It.IsAny>()))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+ var options = new Dictionary
+ {
+ ["state"] = "stopped"
+ };
+
+ // Act
+ service.UpdateResource(CreateSession(), "vm:100", options);
+
+ // Assert
+ mockClient.Verify(c => c.PutAsync(
+ "cluster/ha/resources/vm%3A100",
+ It.Is>(d =>
+ d["state"] == "stopped")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void DeleteResource_CallsDeleteWithEncodedUrl()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.DeleteAsync("cluster/ha/resources/vm%3A100"))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ service.DeleteResource(CreateSession(), "vm:100");
+
+ // Assert
+ mockClient.Verify(c => c.DeleteAsync("cluster/ha/resources/vm%3A100"), Times.Once);
+ }
+
+ [Fact]
+ public void MigrateResource_PostsNodeToEncodedUrl()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve1:000ABC:00000001:5F1234AB:hamigrate::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/ha/resources/vm%3A100/migrate",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var upid = service.MigrateResource(CreateSession(), "vm:100", "pve2");
+
+ // Assert
+ Assert.Contains("hamigrate", upid);
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/ha/resources/vm%3A100/migrate",
+ It.Is>(d =>
+ d["node"] == "pve2")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void RelocateResource_PostsNodeToEncodedUrl()
+ {
+ // Arrange
+ var json = @"{""data"": ""UPID:pve1:000DEF:00000002:5F1234AC:harelocate::root@pam:""}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/ha/resources/vm%3A100/relocate",
+ It.IsAny>()))
+ .ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var upid = service.RelocateResource(CreateSession(), "vm:100", "pve3");
+
+ // Assert
+ Assert.Contains("harelocate", upid);
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/ha/resources/vm%3A100/relocate",
+ It.Is>(d =>
+ d["node"] == "pve3")),
+ Times.Once);
+ }
+
+ // -----------------------------------------------------------------
+ // Groups
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void GetGroups_ReturnsGroupArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 1, ""nofailback"": 0, ""comment"": ""Primary group""},
+ {""group"": ""grp2"", ""nodes"": ""pve2,pve3""}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/groups")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var groups = service.GetGroups(CreateSession());
+
+ // Assert
+ Assert.Equal(2, groups.Length);
+ Assert.Equal("grp1", groups[0].Group);
+ Assert.Equal("pve1:2,pve2:1", groups[0].Nodes);
+ Assert.Equal(1, groups[0].Restricted);
+ Assert.Equal(0, groups[0].NoFailback);
+ Assert.Equal("Primary group", groups[0].Comment);
+ Assert.Equal("grp2", groups[1].Group);
+ Assert.Equal("pve2,pve3", groups[1].Nodes);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/groups"), Times.Once);
+ }
+
+ [Fact]
+ public void GetGroup_ReturnsSingleGroup()
+ {
+ // Arrange
+ var json = @"{""data"": {""group"": ""grp1"", ""nodes"": ""pve1:2,pve2:1"", ""restricted"": 0}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/groups/grp1")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var group = service.GetGroup(CreateSession(), "grp1");
+
+ // Assert
+ Assert.Equal("grp1", group.Group);
+ Assert.Equal("pve1:2,pve2:1", group.Nodes);
+ Assert.Equal(0, group.Restricted);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/groups/grp1"), Times.Once);
+ }
+
+ [Fact]
+ public void CreateGroup_PostsGroupNodesAndOptions()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/ha/groups",
+ It.IsAny>()))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+ var options = new Dictionary
+ {
+ ["restricted"] = "1",
+ ["comment"] = "Test group"
+ };
+
+ // Act
+ service.CreateGroup(CreateSession(), "grp1", "pve1:2,pve2:1", options);
+
+ // Assert
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/ha/groups",
+ It.Is>(d =>
+ d["group"] == "grp1" &&
+ d["nodes"] == "pve1:2,pve2:1" &&
+ d["restricted"] == "1" &&
+ d["comment"] == "Test group")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void DeleteGroup_CallsDeleteWithEncodedUrl()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.DeleteAsync("cluster/ha/groups/grp1"))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ service.DeleteGroup(CreateSession(), "grp1");
+
+ // Assert
+ mockClient.Verify(c => c.DeleteAsync("cluster/ha/groups/grp1"), Times.Once);
+ }
+
+ // -----------------------------------------------------------------
+ // Status
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void GetStatus_ReturnsStatusArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""id"": ""quorum"", ""type"": ""quorum"", ""node"": ""pve1"", ""status"": ""OK"", ""timestamp"": 1700000000},
+ {""id"": ""manager"", ""type"": ""manager"", ""node"": ""pve1"", ""status"": ""OK"", ""crm_state"": ""S_IDLE""},
+ {""id"": ""service:vm:100"", ""type"": ""service"", ""node"": ""pve1"", ""status"": ""started"", ""request_state"": ""started""}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/status/current")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var statuses = service.GetStatus(CreateSession());
+
+ // Assert
+ Assert.Equal(3, statuses.Length);
+ Assert.Equal("quorum", statuses[0].Id);
+ Assert.Equal("quorum", statuses[0].Type);
+ Assert.Equal("pve1", statuses[0].Node);
+ Assert.Equal("OK", statuses[0].Status);
+ Assert.Equal(1700000000L, statuses[0].Timestamp);
+ Assert.Equal("manager", statuses[1].Id);
+ Assert.Equal("S_IDLE", statuses[1].CrmState);
+ Assert.Equal("service:vm:100", statuses[2].Id);
+ Assert.Equal("started", statuses[2].RequestState);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/status/current"), Times.Once);
+ }
+
+ [Fact]
+ public void GetManagerStatus_ReturnsJObject()
+ {
+ // Arrange
+ var json = @"{""data"": {""manager_status"": {""master_node"": ""pve1""}, ""quorum"": {""node"": ""pve1""}}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/status/manager_status")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var status = service.GetManagerStatus(CreateSession());
+
+ // Assert
+ Assert.NotNull(status);
+ Assert.NotNull(status["manager_status"]);
+ Assert.NotNull(status["quorum"]);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/status/manager_status"), Times.Once);
+ }
+
+ // -----------------------------------------------------------------
+ // Rules
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void GetRules_ReturnsRuleArray()
+ {
+ // Arrange
+ var json = @"{""data"": [
+ {""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled"", ""comment"": ""Location rule""},
+ {""rule"": ""rule2"", ""type"": ""colocation"", ""state"": ""disabled""}
+ ]}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/rules")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var rules = service.GetRules(CreateSession());
+
+ // Assert
+ Assert.Equal(2, rules.Length);
+ Assert.Equal("rule1", rules[0].Rule);
+ Assert.Equal("location", rules[0].Type);
+ Assert.Equal("enabled", rules[0].State);
+ Assert.Equal("Location rule", rules[0].Comment);
+ Assert.Equal("rule2", rules[1].Rule);
+ Assert.Equal("colocation", rules[1].Type);
+ Assert.Equal("disabled", rules[1].State);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/rules"), Times.Once);
+ }
+
+ [Fact]
+ public void GetRule_ReturnsSingleRule()
+ {
+ // Arrange
+ var json = @"{""data"": {""rule"": ""rule1"", ""type"": ""location"", ""state"": ""enabled""}}";
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetAsync("cluster/ha/rules/rule1")).ReturnsAsync(json);
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ var rule = service.GetRule(CreateSession(), "rule1");
+
+ // Assert
+ Assert.Equal("rule1", rule.Rule);
+ Assert.Equal("location", rule.Type);
+ Assert.Equal("enabled", rule.State);
+ mockClient.Verify(c => c.GetAsync("cluster/ha/rules/rule1"), Times.Once);
+ }
+
+ [Fact]
+ public void CreateRule_PostsOptions()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.PostAsync(
+ "cluster/ha/rules",
+ It.IsAny>()))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+ var options = new Dictionary
+ {
+ ["rule"] = "rule1",
+ ["type"] = "location",
+ ["state"] = "enabled"
+ };
+
+ // Act
+ service.CreateRule(CreateSession(), options);
+
+ // Assert
+ mockClient.Verify(c => c.PostAsync(
+ "cluster/ha/rules",
+ It.Is>(d =>
+ d["rule"] == "rule1" &&
+ d["type"] == "location" &&
+ d["state"] == "enabled")),
+ Times.Once);
+ }
+
+ [Fact]
+ public void DeleteRule_CallsDeleteWithEncodedUrl()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.DeleteAsync("cluster/ha/rules/rule1"))
+ .ReturnsAsync("{}");
+ var service = new HaService(mockClient.Object);
+
+ // Act
+ service.DeleteRule(CreateSession(), "rule1");
+
+ // Assert
+ mockClient.Verify(c => c.DeleteAsync("cluster/ha/rules/rule1"), Times.Once);
+ }
+
+ // -----------------------------------------------------------------
+ // Null session tests
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void GetResources_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new HaService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetResources(null!));
+ }
+
+ [Fact]
+ public void GetGroups_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new HaService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetGroups(null!));
+ }
+
+ [Fact]
+ public void GetStatus_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new HaService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetStatus(null!));
+ }
+
+ [Fact]
+ public void GetRules_NullSession_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var mockClient = new Mock();
+ var service = new HaService(mockClient.Object);
+
+ // Act & Assert
+ Assert.Throws(() => service.GetRules(null!));
+ }
+
+ [Fact]
+ public void Constructor_NullClient_ThrowsArgumentNullException()
+ {
+ Assert.Throws(() => new HaService(null!));
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1
new file mode 100644
index 0000000..fa2bdb8
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Cluster/ClusterConfigCmdlets.Tests.ps1
@@ -0,0 +1,613 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for cluster config and management cmdlets:
+ Get-PveClusterStatus, Get-PveClusterNextId, Get-PveClusterOption,
+ Set-PveClusterOption, Get-PveClusterConfig, Get-PveClusterConfigNode,
+ Add-PveClusterConfigNode, Remove-PveClusterConfigNode,
+ Get-PveClusterJoinInfo, Add-PveClusterMember, New-PveCluster.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @(
+ 'Get-PveClusterStatus',
+ 'Get-PveClusterNextId',
+ 'Get-PveClusterOption',
+ 'Set-PveClusterOption',
+ 'Get-PveClusterConfig',
+ 'Get-PveClusterConfigNode',
+ 'Add-PveClusterConfigNode',
+ 'Remove-PveClusterConfigNode',
+ 'Get-PveClusterJoinInfo',
+ 'Add-PveClusterMember',
+ 'New-PveCluster'
+ )) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterStatus
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterStatus' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterStatus' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterStatus'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterStatus'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterStatus'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterStatus'
+ { Get-PveClusterStatus -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterNextId
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterNextId' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterNextId' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional VmId parameter' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $script:Cmd.Parameters.ContainsKey('VmId') | Should -BeTrue
+ }
+ It 'VmId should not be Mandatory' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $p = $script:Cmd.Parameters['VmId']
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -BeNullOrEmpty
+ }
+ It 'VmId should have ValidateRange(100, 999999999)' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $p = $script:Cmd.Parameters['VmId']
+ $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] }
+ $rangeAttr | Should -Not -BeNullOrEmpty
+ $rangeAttr.MinRange | Should -Be 100
+ $rangeAttr.MaxRange | Should -Be 999999999
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterNextId'
+ { Get-PveClusterNextId -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterOption
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterOption' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterOption' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterOption'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterOption'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterOption'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterOption'
+ { Get-PveClusterOption -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveClusterOption
+# ---------------------------------------------------------------------------
+Describe 'Set-PveClusterOption' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveClusterOption' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Keyboard parameter' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd.Parameters.ContainsKey('Keyboard') | Should -BeTrue
+ }
+ It 'Keyboard should have ValidateSet' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $p = $script:Cmd.Parameters['Keyboard']
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Language parameter' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd.Parameters.ContainsKey('Language') | Should -BeTrue
+ }
+ It 'Should have Console parameter' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd.Parameters.ContainsKey('Console') | Should -BeTrue
+ }
+ It 'Console should have ValidateSet' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $p = $script:Cmd.Parameters['Console']
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Fencing parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $p = $script:Cmd.Parameters['Fencing']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveClusterOption'
+ { Set-PveClusterOption -Keyboard 'en-us' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterConfig
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterConfig' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterConfig' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterConfig'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterConfig'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterConfig'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterConfig'
+ { Get-PveClusterConfig -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterConfigNode
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterConfigNode' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterConfigNode' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterConfigNode'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterConfigNode'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterConfigNode'
+ { Get-PveClusterConfigNode -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Add-PveClusterConfigNode
+# ---------------------------------------------------------------------------
+Describe 'Add-PveClusterConfigNode' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Add-PveClusterConfigNode' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Node parameter' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $p = $script:Cmd.Parameters['Node']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional NewNodeIp parameter' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('NewNodeIp') | Should -BeTrue
+ }
+ It 'Should have optional NodeId parameter' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('NodeId') | Should -BeTrue
+ }
+ It 'Should have optional Votes parameter' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('Votes') | Should -BeTrue
+ }
+ It 'Should have optional Force switch' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('Force') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Add-PveClusterConfigNode'
+ { Add-PveClusterConfigNode -Node 'pve2' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveClusterConfigNode
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveClusterConfigNode' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveClusterConfigNode' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Node parameter' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $p = $script:Cmd.Parameters['Node']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveClusterConfigNode'
+ { Remove-PveClusterConfigNode -Node 'pve2' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveClusterJoinInfo
+# ---------------------------------------------------------------------------
+Describe 'Get-PveClusterJoinInfo' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveClusterJoinInfo' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Node parameter' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ $script:Cmd.Parameters.ContainsKey('Node') | Should -BeTrue
+ }
+ It 'Node should not be Mandatory' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ $p = $script:Cmd.Parameters['Node']
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveClusterJoinInfo'
+ { Get-PveClusterJoinInfo -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Add-PveClusterMember
+# ---------------------------------------------------------------------------
+Describe 'Add-PveClusterMember' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Add-PveClusterMember' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Hostname parameter' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $p = $script:Cmd.Parameters['Hostname']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have mandatory Fingerprint parameter' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $p = $script:Cmd.Parameters['Fingerprint']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have mandatory Password parameter' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $p = $script:Cmd.Parameters['Password']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Password should be SecureString type' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $script:Cmd.Parameters['Password'].ParameterType | Should -Be ([System.Security.SecureString])
+ }
+ It 'Should have optional Force switch' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $script:Cmd.Parameters.ContainsKey('Force') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Add-PveClusterMember'
+ $secPw = ConvertTo-SecureString 'test' -AsPlainText -Force
+ { Add-PveClusterMember -Hostname 'pve1' -Fingerprint 'AA:BB' -Password $secPw -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveCluster
+# ---------------------------------------------------------------------------
+Describe 'New-PveCluster' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveCluster' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory ClusterName parameter' {
+ Skip-IfMissing 'New-PveCluster'
+ $p = $script:Cmd.Parameters['ClusterName']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'ClusterName should have ValidateLength(1, 15)' {
+ Skip-IfMissing 'New-PveCluster'
+ $p = $script:Cmd.Parameters['ClusterName']
+ $lenAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateLengthAttribute] }
+ $lenAttr | Should -Not -BeNullOrEmpty
+ $lenAttr.MinLength | Should -Be 1
+ $lenAttr.MaxLength | Should -Be 15
+ }
+ It 'Should have optional NodeId parameter' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd.Parameters.ContainsKey('NodeId') | Should -BeTrue
+ }
+ It 'Should have optional Votes parameter' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd.Parameters.ContainsKey('Votes') | Should -BeTrue
+ }
+ It 'Should have optional Links parameter' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd.Parameters.ContainsKey('Links') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'New-PveCluster'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'New-PveCluster'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'New-PveCluster'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveCluster'
+ { New-PveCluster -ClusterName 'testcluster' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1
new file mode 100644
index 0000000..501cbf9
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/HA/HaCmdlets.Tests.ps1
@@ -0,0 +1,831 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Pester 5 tests for HA (High Availability) cmdlets:
+ Get-PveHaResource, New-PveHaResource, Set-PveHaResource,
+ Remove-PveHaResource, Move-PveHaResource,
+ Get-PveHaGroup, New-PveHaGroup, Set-PveHaGroup, Remove-PveHaGroup,
+ Get-PveHaStatus,
+ Get-PveHaRule, New-PveHaRule, Set-PveHaRule, Remove-PveHaRule.
+
+ All tests are fully offline — no live Proxmox VE target is required.
+ If a cmdlet is not yet compiled the test is marked Skipped.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/../_TestHelper.ps1
+
+ $script:Availability = @{}
+ foreach ($name in @(
+ 'Get-PveHaResource',
+ 'New-PveHaResource',
+ 'Set-PveHaResource',
+ 'Remove-PveHaResource',
+ 'Move-PveHaResource',
+ 'Get-PveHaGroup',
+ 'New-PveHaGroup',
+ 'Set-PveHaGroup',
+ 'Remove-PveHaGroup',
+ 'Get-PveHaStatus',
+ 'Get-PveHaRule',
+ 'New-PveHaRule',
+ 'Set-PveHaRule',
+ 'Remove-PveHaRule'
+ )) {
+ $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue)
+ }
+
+ function Skip-IfMissing([string]$Name) {
+ if (-not $script:Availability[$Name]) {
+ Set-ItResult -Skipped -Because "$Name is not yet implemented in this build"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveHaResource
+# ---------------------------------------------------------------------------
+Describe 'Get-PveHaResource' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveHaResource' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveHaResource'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveHaResource'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Sid parameter' {
+ Skip-IfMissing 'Get-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Sid') | Should -BeTrue
+ }
+ It 'Sid should not be Mandatory' {
+ Skip-IfMissing 'Get-PveHaResource'
+ $p = $script:Cmd.Parameters['Sid']
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveHaResource'
+ { Get-PveHaResource -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveHaResource
+# ---------------------------------------------------------------------------
+Describe 'New-PveHaResource' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveHaResource' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveHaResource'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'New-PveHaResource'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Sid parameter' {
+ Skip-IfMissing 'New-PveHaResource'
+ $p = $script:Cmd.Parameters['Sid']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional State parameter with ValidateSet' {
+ Skip-IfMissing 'New-PveHaResource'
+ $p = $script:Cmd.Parameters['State']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional Group parameter' {
+ Skip-IfMissing 'New-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue
+ }
+ It 'Should have optional MaxRelocate parameter with ValidateRange' {
+ Skip-IfMissing 'New-PveHaResource'
+ $p = $script:Cmd.Parameters['MaxRelocate']
+ $p | Should -Not -BeNullOrEmpty
+ $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] }
+ $rangeAttr | Should -Not -BeNullOrEmpty
+ $rangeAttr.MinRange | Should -Be 0
+ $rangeAttr.MaxRange | Should -Be 10
+ }
+ It 'Should have optional MaxRestart parameter with ValidateRange' {
+ Skip-IfMissing 'New-PveHaResource'
+ $p = $script:Cmd.Parameters['MaxRestart']
+ $p | Should -Not -BeNullOrEmpty
+ $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] }
+ $rangeAttr | Should -Not -BeNullOrEmpty
+ $rangeAttr.MinRange | Should -Be 0
+ $rangeAttr.MaxRange | Should -Be 10
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'New-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'New-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveHaResource'
+ { New-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveHaResource
+# ---------------------------------------------------------------------------
+Describe 'Set-PveHaResource' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveHaResource' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Sid parameter' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $p = $script:Cmd.Parameters['Sid']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional State parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $p = $script:Cmd.Parameters['State']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Set-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveHaResource'
+ { Set-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveHaResource
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveHaResource' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaResource' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Sid parameter' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $p = $script:Cmd.Parameters['Sid']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveHaResource'
+ { Remove-PveHaResource -Sid 'vm:100' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Move-PveHaResource
+# ---------------------------------------------------------------------------
+Describe 'Move-PveHaResource' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Move-PveHaResource' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Sid parameter' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $p = $script:Cmd.Parameters['Sid']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have mandatory Node parameter' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $p = $script:Cmd.Parameters['Node']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Mode parameter with ValidateSet(Migrate, Relocate)' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $p = $script:Cmd.Parameters['Mode']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ $vsAttr.ValidValues | Should -Contain 'Migrate'
+ $vsAttr.ValidValues | Should -Contain 'Relocate'
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Move-PveHaResource'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Move-PveHaResource'
+ { Move-PveHaResource -Sid 'vm:100' -Node 'pve2' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveHaGroup
+# ---------------------------------------------------------------------------
+Describe 'Get-PveHaGroup' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveHaGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Group parameter' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Group') | Should -BeTrue
+ }
+ It 'Group should not be Mandatory' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ $p = $script:Cmd.Parameters['Group']
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveHaGroup'
+ { Get-PveHaGroup -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveHaGroup
+# ---------------------------------------------------------------------------
+Describe 'New-PveHaGroup' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveHaGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Group parameter' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $p = $script:Cmd.Parameters['Group']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have mandatory Nodes parameter' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $p = $script:Cmd.Parameters['Nodes']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional Restricted switch' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Restricted') | Should -BeTrue
+ }
+ It 'Should have optional NoFailback switch' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('NoFailback') | Should -BeTrue
+ }
+ It 'Should have optional Comment parameter' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'New-PveHaGroup'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveHaGroup'
+ { New-PveHaGroup -Group 'grp1' -Nodes 'pve1:1,pve2:2' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveHaGroup
+# ---------------------------------------------------------------------------
+Describe 'Set-PveHaGroup' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveHaGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Group parameter' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $p = $script:Cmd.Parameters['Group']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional Nodes parameter' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Nodes') | Should -BeTrue
+ }
+ It 'Should have optional Restricted parameter with ValidateRange(0, 1)' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $p = $script:Cmd.Parameters['Restricted']
+ $p | Should -Not -BeNullOrEmpty
+ $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] }
+ $rangeAttr | Should -Not -BeNullOrEmpty
+ $rangeAttr.MinRange | Should -Be 0
+ $rangeAttr.MaxRange | Should -Be 1
+ }
+ It 'Should have optional NoFailback parameter with ValidateRange(0, 1)' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $p = $script:Cmd.Parameters['NoFailback']
+ $p | Should -Not -BeNullOrEmpty
+ $rangeAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] }
+ $rangeAttr | Should -Not -BeNullOrEmpty
+ $rangeAttr.MinRange | Should -Be 0
+ $rangeAttr.MaxRange | Should -Be 1
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveHaGroup'
+ { Set-PveHaGroup -Group 'grp1' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveHaGroup
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveHaGroup' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaGroup' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Group parameter' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $p = $script:Cmd.Parameters['Group']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveHaGroup'
+ { Remove-PveHaGroup -Group 'grp1' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveHaStatus
+# ---------------------------------------------------------------------------
+Describe 'Get-PveHaStatus' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveHaStatus' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveHaStatus'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveHaStatus'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveHaStatus'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveHaStatus'
+ { Get-PveHaStatus -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Get-PveHaRule
+# ---------------------------------------------------------------------------
+Describe 'Get-PveHaRule' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Get-PveHaRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Get-PveHaRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Get-PveHaRule'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have optional Rule parameter' {
+ Skip-IfMissing 'Get-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Rule') | Should -BeTrue
+ }
+ It 'Rule should not be Mandatory' {
+ Skip-IfMissing 'Get-PveHaRule'
+ $p = $script:Cmd.Parameters['Rule']
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Get-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Get-PveHaRule'
+ { Get-PveHaRule -ErrorAction Stop } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# New-PveHaRule
+# ---------------------------------------------------------------------------
+Describe 'New-PveHaRule' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'New-PveHaRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'New-PveHaRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'New-PveHaRule'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Type parameter' {
+ Skip-IfMissing 'New-PveHaRule'
+ $p = $script:Cmd.Parameters['Type']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional State parameter with ValidateSet' {
+ Skip-IfMissing 'New-PveHaRule'
+ $p = $script:Cmd.Parameters['State']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional Comment parameter' {
+ Skip-IfMissing 'New-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue
+ }
+ It 'Should have optional Properties parameter' {
+ Skip-IfMissing 'New-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Properties') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'New-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'New-PveHaRule'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'New-PveHaRule'
+ { New-PveHaRule -Type 'location' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Set-PveHaRule
+# ---------------------------------------------------------------------------
+Describe 'Set-PveHaRule' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Set-PveHaRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Rule parameter' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $p = $script:Cmd.Parameters['Rule']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have mandatory Type parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $p = $script:Cmd.Parameters['Type']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional State parameter with ValidateSet' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $p = $script:Cmd.Parameters['State']
+ $p | Should -Not -BeNullOrEmpty
+ $vsAttr = $p.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] }
+ $vsAttr | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have optional Properties parameter' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Properties') | Should -BeTrue
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Set-PveHaRule'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Set-PveHaRule'
+ { Set-PveHaRule -Rule 'rule1' -Type 'node-affinity' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Remove-PveHaRule
+# ---------------------------------------------------------------------------
+Describe 'Remove-PveHaRule' -Tag 'Unit' {
+
+ BeforeAll { $script:Cmd = Get-Command 'Remove-PveHaRule' -ErrorAction SilentlyContinue }
+
+ Context 'Command existence' {
+ It 'Should be available after module import' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $script:Cmd | Should -Not -BeNullOrEmpty
+ }
+ It 'Should be a CmdletInfo (binary cmdlet)' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $script:Cmd.CommandType | Should -Be 'Cmdlet'
+ }
+ }
+
+ Context 'Parameter metadata' {
+ It 'Should have mandatory Rule parameter' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $p = $script:Cmd.Parameters['Rule']
+ $p | Should -Not -BeNullOrEmpty
+ $isMandatory = $p.ParameterSets.Values | Where-Object { $_.IsMandatory }
+ $isMandatory | Should -Not -BeNullOrEmpty
+ }
+ It 'Should have Session parameter' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue
+ }
+ It 'Should support ShouldProcess' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.SupportsShouldProcess | Should -BeTrue
+ }
+ It 'Should have ConfirmImpact High' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ $cmdletAttr = $script:Cmd.ImplementingType.GetCustomAttributes($true) |
+ Where-Object { $_ -is [System.Management.Automation.CmdletAttribute] }
+ $cmdletAttr.ConfirmImpact | Should -Be 'High'
+ }
+ }
+
+ Context 'Without active session' {
+ It 'Should throw when no session is active' {
+ Skip-IfMissing 'Remove-PveHaRule'
+ { Remove-PveHaRule -Rule 'rule1' -ErrorAction Stop -Confirm:$false } |
+ Should -Throw '*No active Proxmox VE session*'
+ }
+ }
+}
diff --git a/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1 b/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1
new file mode 100644
index 0000000..405ccc7
--- /dev/null
+++ b/tests/PSProxmoxVE.Tests/Integration/16_Cluster.Tests.ps1
@@ -0,0 +1,406 @@
+#Requires -Module Pester
+<#
+.SYNOPSIS
+ Integration tests for cluster configuration and HA lifecycle.
+
+ Exercises the full cluster lifecycle: create cluster on node A,
+ join node B, manage HA groups/rules, and verify cluster state.
+
+ Nodes must be provisioned STANDALONE (not clustered). These tests
+ create a cluster during the run. Cleanup is handled by reprovisioning
+ (2-node cluster cannot be torn down via API due to quorum loss).
+
+ WARNING: These tests CREATE a cluster on the target nodes.
+ Never run against a production cluster.
+#>
+
+BeforeAll {
+ . $PSScriptRoot/_IntegrationHelper.ps1
+ Connect-TestPve
+
+ # --- State tracking ---
+ $script:ClusterCreated = $false
+ $script:JoinInfo = $null
+ $script:NodeBName = $null
+ $script:HaRuleCreated = $false
+ $script:HaTestVmId = $null
+
+ # --- Cluster-specific skip helpers ---
+
+ function script:Skip-IfNoCluster {
+ if (Skip-IfNoTarget) { return $true }
+ if (-not $script:ClusterCreated) {
+ Set-ItResult -Skipped -Because 'Cluster was not created (multi-node env vars may not be set)'
+ return $true
+ }
+ return $false
+ }
+
+ function script:Skip-IfPve9HaGroups {
+ # PVE 9.0+ migrated HA groups to rules — groups API returns 500
+ if (Skip-IfNoCluster) { return $true }
+ if ($script:PveVersion -and [int]$script:PveVersion -ge 9) {
+ Set-ItResult -Skipped -Because 'PVE 9.0+ migrated HA groups to rules — use Get/New/Set/Remove-PveHaRule instead'
+ return $true
+ }
+ return $false
+ }
+}
+
+AfterAll {
+ # Best-effort cleanup of HA artifacts
+ if ($script:HaRuleCreated) {
+ try { Remove-PveHaRule -Rule 'pester-rule-1' -Confirm:$false -ErrorAction SilentlyContinue } catch { }
+ }
+ if ($script:HaTestVmId) {
+ try { Remove-PveHaResource -Sid "vm:$($script:HaTestVmId)" -Confirm:$false -ErrorAction SilentlyContinue } catch { }
+ try { Remove-PveVm -Node $script:Node -VmId $script:HaTestVmId -Confirm:$false -ErrorAction SilentlyContinue } catch { }
+ }
+
+ # Node removal from a 2-node cluster is not possible via API (quorum loss).
+ # Test infrastructure handles cleanup via reprovisioning.
+ Disconnect-TestPve
+}
+
+Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' {
+
+ # -------------------------------------------------------------------
+ Context 'Pre-cluster reads on standalone node' {
+ It 'Get-PveClusterStatus returns node info' {
+ if (Skip-IfNoTarget) { return }
+
+ $status = Get-PveClusterStatus -ErrorAction Stop
+ $status | Should -Not -BeNullOrEmpty
+ @($status).Count | Should -BeGreaterOrEqual 1
+ }
+
+ It 'Get-PveClusterNextId returns valid VMID' {
+ if (Skip-IfNoTarget) { return }
+
+ $nextId = Get-PveClusterNextId -ErrorAction Stop
+ $nextId | Should -Not -BeNullOrEmpty
+ [int]$nextId | Should -BeGreaterOrEqual 100
+ }
+
+ It 'Get-PveClusterOption returns options' {
+ if (Skip-IfNoTarget) { return }
+
+ $options = Get-PveClusterOption -ErrorAction Stop
+ $options | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Get-PveClusterConfig returns config' {
+ if (Skip-IfNoTarget) { return }
+
+ { Get-PveClusterConfig -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'Get-PveHaStatus returns status without throwing' {
+ if (Skip-IfNoTarget) { return }
+
+ { Get-PveHaStatus -ErrorAction Stop } | Should -Not -Throw
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'Create cluster on node A' {
+ It 'New-PveCluster creates a cluster named pester-cluster' {
+ if (Skip-IfNoNodeB) { return }
+
+ # Already connected as root@pam via Connect-TestPve
+ $result = New-PveCluster -ClusterName 'pester-cluster' -Wait -Confirm:$false -ErrorAction Stop
+ $result | Should -Not -BeNullOrEmpty
+ $script:ClusterCreated = $true
+
+ # Allow corosync to fully stabilize
+ Start-Sleep -Seconds 5
+ }
+
+ It 'Get-PveClusterStatus shows cluster formed' {
+ if (Skip-IfNoCluster) { return }
+
+ $status = Get-PveClusterStatus -ErrorAction Stop
+ @($status).Count | Should -BeGreaterOrEqual 1
+ }
+
+ It 'Get-PveClusterConfigNode lists node A' {
+ if (Skip-IfNoCluster) { return }
+
+ $nodes = Get-PveClusterConfigNode -ErrorAction Stop
+ $nodes | Should -Not -BeNullOrEmpty
+ @($nodes).Count | Should -BeGreaterOrEqual 1
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'Join node B to cluster' {
+ It 'Get-PveClusterJoinInfo returns join token from node A' {
+ if (Skip-IfNoNodeB) { return }
+ if (Skip-IfNoCluster) { return }
+
+ $script:JoinInfo = Get-PveClusterJoinInfo -ErrorAction Stop
+ $script:JoinInfo | Should -Not -BeNullOrEmpty
+
+ # Nodelist is List> — access pve_fp from first entry
+ $script:JoinInfo.Nodelist | Should -Not -BeNullOrEmpty
+ $script:JoinInfo.Nodelist.Count | Should -BeGreaterOrEqual 1
+ $script:Fingerprint = $script:JoinInfo.Nodelist[0]['pve_fp']
+ $script:Fingerprint | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Add-PveClusterMember joins node B to cluster' {
+ if (Skip-IfNoNodeB) { return }
+ if (Skip-IfNoCluster) { return }
+ if (-not $script:Fingerprint) {
+ Set-ItResult -Skipped -Because 'Fingerprint was not extracted from join info'
+ return
+ }
+
+ # Connect to node B with root@pam
+ $secPw = ConvertTo-SecureString $script:PasswordB -AsPlainText -Force
+ $credB = New-Object System.Management.Automation.PSCredential('root@pam', $secPw)
+ Connect-PveServer `
+ -Server $script:HostB `
+ -Port $script:Port `
+ -Credential $credB `
+ -SkipCertificateCheck
+
+ $fingerprint = $script:Fingerprint
+ $joinPw = ConvertTo-SecureString $script:Password -AsPlainText -Force
+
+ try {
+ $result = Add-PveClusterMember `
+ -Hostname $script:Host_ `
+ -Fingerprint $fingerprint `
+ -Password $joinPw `
+ -Wait `
+ -Confirm:$false `
+ -ErrorAction Stop
+
+ $result | Should -Not -BeNullOrEmpty
+
+ # Brief pause for pmxcfs to sync
+ Start-Sleep -Seconds 5
+ }
+ finally {
+ # Always reconnect to node A
+ Connect-TestPve
+ }
+ }
+
+ It 'Get-PveClusterConfigNode shows both nodes' {
+ if (Skip-IfNoNodeB) { return }
+ if (Skip-IfNoCluster) { return }
+
+ $nodes = Get-PveClusterConfigNode -ErrorAction Stop
+ @($nodes).Count | Should -BeGreaterOrEqual 2
+
+ # Discover and store node B's name
+ $nodeNames = @($nodes) | ForEach-Object { $_.Name }
+ $script:NodeBName = $nodeNames | Where-Object { $_ -ne $script:Node } | Select-Object -First 1
+ $script:NodeBName | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Get-PveClusterStatus shows 2 nodes online' {
+ if (Skip-IfNoNodeB) { return }
+ if (Skip-IfNoCluster) { return }
+
+ $status = Get-PveClusterStatus -ErrorAction Stop
+ $nodeEntries = @($status) | Where-Object { $_.Type -eq 'node' }
+ @($nodeEntries).Count | Should -BeGreaterOrEqual 2
+
+ $onlineNodes = @($nodeEntries) | Where-Object { $_.Online -eq 1 }
+ @($onlineNodes).Count | Should -BeGreaterOrEqual 2
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'Cluster options' {
+ It 'Set-PveClusterOption sets keyboard to en-us' {
+ if (Skip-IfNoCluster) { return }
+
+ $options = Get-PveClusterOption -ErrorAction Stop
+ $script:OriginalKeyboard = $options.Keyboard
+
+ Set-PveClusterOption -Keyboard 'en-us' -Confirm:$false -ErrorAction Stop
+ }
+
+ It 'Get-PveClusterOption reflects the keyboard change' {
+ if (Skip-IfNoCluster) { return }
+
+ $options = Get-PveClusterOption -ErrorAction Stop
+ $options.Keyboard | Should -Be 'en-us'
+ }
+
+ It 'Set-PveClusterOption restores original keyboard' {
+ if (Skip-IfNoCluster) { return }
+
+ if ($script:OriginalKeyboard) {
+ Set-PveClusterOption -Keyboard $script:OriginalKeyboard -Confirm:$false -ErrorAction Stop
+ }
+ else {
+ Set-PveClusterOption -Delete 'keyboard' -Confirm:$false -ErrorAction Stop
+ }
+
+ $options = Get-PveClusterOption -ErrorAction Stop
+ $options.Keyboard | Should -Be $script:OriginalKeyboard
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'HA group management (PVE 8 only)' {
+ It 'New-PveHaGroup creates test group' {
+ if (Skip-IfPve9HaGroups) { return }
+
+ $nodeList = "$($script:Node):1"
+ if ($script:NodeBName) {
+ $nodeList += ",$($script:NodeBName):2"
+ }
+
+ { New-PveHaGroup -Group 'pester-group' -Nodes $nodeList -Confirm:$false -ErrorAction Stop } |
+ Should -Not -Throw
+ }
+
+ It 'Get-PveHaGroup lists groups including pester-group' {
+ if (Skip-IfPve9HaGroups) { return }
+
+ $groups = @(Get-PveHaGroup -ErrorAction Stop)
+ $match = $groups | Where-Object { $_.Group -eq 'pester-group' }
+ $match | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Get-PveHaGroup returns specific group' {
+ if (Skip-IfPve9HaGroups) { return }
+
+ $group = Get-PveHaGroup -Group 'pester-group' -ErrorAction Stop
+ $group | Should -Not -BeNullOrEmpty
+ $group.Group | Should -Be 'pester-group'
+ }
+
+ It 'Set-PveHaGroup updates comment' {
+ if (Skip-IfPve9HaGroups) { return }
+
+ { Set-PveHaGroup -Group 'pester-group' -Comment 'pester test' -Confirm:$false -ErrorAction Stop } |
+ Should -Not -Throw
+ }
+
+ It 'Remove-PveHaGroup deletes test group' {
+ if (Skip-IfPve9HaGroups) { return }
+
+ { Remove-PveHaGroup -Group 'pester-group' -Confirm:$false -ErrorAction Stop } |
+ Should -Not -Throw
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'HA rules CRUD (PVE 9.0+)' {
+ It 'Get-PveHaRule returns rules list without throwing' {
+ if (Skip-IfNoPve9) { return }
+
+ { Get-PveHaRule -ErrorAction Stop } | Should -Not -Throw
+ }
+
+ It 'New-PveHaRule creates a node-affinity rule' {
+ if (Skip-IfNoPve9) { return }
+ if (Skip-IfNoCluster) { return }
+
+ # HA rules require a managed resource — create a minimal VM first
+ $script:HaTestVmId = Get-PveClusterNextId -ErrorAction Stop
+ New-PveVm -Node $script:Node -VmId $script:HaTestVmId `
+ -Name 'pester-ha-test' -Memory 128 -Cores 1 `
+ -Confirm:$false -ErrorAction Stop | Out-Null
+
+ New-PveHaResource -Sid "vm:$($script:HaTestVmId)" `
+ -State 'disabled' `
+ -Confirm:$false -ErrorAction Stop
+
+ { New-PveHaRule -Type 'node-affinity' `
+ -Properties @{
+ rule = 'pester-rule-1'
+ resources = "vm:$($script:HaTestVmId)"
+ nodes = $script:Node
+ } `
+ -Comment 'Pester test rule' `
+ -Confirm:$false `
+ -ErrorAction Stop } | Should -Not -Throw
+
+ $script:HaRuleCreated = $true
+ }
+
+ It 'Get-PveHaRule lists the created rule' {
+ if (Skip-IfNoPve9) { return }
+ if (-not $script:HaRuleCreated) {
+ Set-ItResult -Skipped -Because 'HA rule was not created'
+ return
+ }
+
+ $rules = Get-PveHaRule -ErrorAction Stop
+ $rules | Should -Not -BeNullOrEmpty
+ $match = @($rules) | Where-Object { $_.Rule -eq 'pester-rule-1' }
+ $match | Should -Not -BeNullOrEmpty
+ }
+
+ It 'Get-PveHaRule returns specific rule by ID' {
+ if (Skip-IfNoPve9) { return }
+ if (-not $script:HaRuleCreated) {
+ Set-ItResult -Skipped -Because 'HA rule was not created'
+ return
+ }
+
+ $rule = Get-PveHaRule -Rule 'pester-rule-1' -ErrorAction Stop
+ $rule | Should -Not -BeNullOrEmpty
+ $rule.Rule | Should -Be 'pester-rule-1'
+ $rule.Comment | Should -Be 'Pester test rule'
+ }
+
+ It 'Set-PveHaRule updates the comment' {
+ if (Skip-IfNoPve9) { return }
+ if (-not $script:HaRuleCreated) {
+ Set-ItResult -Skipped -Because 'HA rule was not created'
+ return
+ }
+
+ { Set-PveHaRule -Rule 'pester-rule-1' -Type 'node-affinity' `
+ -Comment 'Updated by Pester' `
+ -Confirm:$false `
+ -ErrorAction Stop } | Should -Not -Throw
+
+ $rule = Get-PveHaRule -Rule 'pester-rule-1' -ErrorAction Stop
+ $rule.Comment | Should -Be 'Updated by Pester'
+ }
+
+ It 'Remove-PveHaRule deletes the rule' {
+ if (Skip-IfNoPve9) { return }
+ if (-not $script:HaRuleCreated) {
+ Set-ItResult -Skipped -Because 'HA rule was not created'
+ return
+ }
+
+ { Remove-PveHaRule -Rule 'pester-rule-1' -Confirm:$false -ErrorAction Stop } |
+ Should -Not -Throw
+
+ $script:HaRuleCreated = $false
+
+ # Clean up HA resource and test VM
+ try { Remove-PveHaResource -Sid "vm:$($script:HaTestVmId)" -Confirm:$false -ErrorAction SilentlyContinue } catch { }
+ try { Remove-PveVm -Node $script:Node -VmId $script:HaTestVmId -Confirm:$false -ErrorAction SilentlyContinue } catch { }
+ $script:HaTestVmId = $null
+
+ # Verify rule is gone
+ $rules = Get-PveHaRule -ErrorAction Stop
+ $match = @($rules) | Where-Object { $_.Rule -eq 'pester-rule-1' }
+ $match | Should -BeNullOrEmpty
+ }
+ }
+
+ # -------------------------------------------------------------------
+ Context 'Verify cluster state at end of test' {
+ It 'Get-PveClusterConfigNode shows both nodes still present' {
+ if (Skip-IfNoNodeB) { return }
+ if (Skip-IfNoCluster) { return }
+
+ $nodes = Get-PveClusterConfigNode -ErrorAction Stop
+ @($nodes).Count | Should -BeGreaterOrEqual 2
+ }
+ }
+}