test: add xUnit, Pester, and integration tests for cluster config + HA

xUnit (47 new tests, 429 total):
- ClusterConfigServiceTests: 25 tests covering all 14 service methods
  including URL encoding, null guards, and error responses
- HaServiceTests: 22 tests covering resources, groups, status, and rules
  with URI encoding verification (vm:100 → vm%3A100)

Pester (187 new tests, 1525 total):
- ClusterConfigCmdlets.Tests.ps1: 11 cmdlets — parameter metadata,
  ValidateSet/ValidateRange/ValidateLength, ShouldProcess, ConfirmImpact
- HaCmdlets.Tests.ps1: 14 cmdlets — same pattern

Integration (ClusterConfig.Integration.Tests.ps1):
- Full 2-node cluster lifecycle: create → join → options → HA groups → cleanup
- Uses root@pam ticket auth for cluster create/join (API tokens lack permission)
- Reconnects with API token after privileged operations
- Skip helpers for standalone (no node B) and non-PVE-9 environments

Fixes:
- GetClusterConfig returns JToken (not JObject) to handle array responses
  from /cluster/config on standalone nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-24 19:12:45 -05:00
parent 4310869943
commit 4e4bc11872
5 changed files with 2859 additions and 0 deletions
@@ -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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
var links = new Dictionary<string, string> { ["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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/nodes/pve2",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(d =>
d["new_node_ip"] == "10.0.0.2")),
Times.Once);
}
[Fact]
public void RemoveConfigNode_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/config/join",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/options",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new ClusterConfigService(mockClient.Object);
var opts = new Dictionary<string, string>
{
["keyboard"] = "de",
["language"] = "de"
};
// Act
service.SetClusterOptions(CreateSession(), opts);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/options",
It.Is<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => 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<IPveHttpClient>();
mockClient.Setup(c => c.GetAsync("cluster/nextid")).ReturnsAsync(json);
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => service.GetNextId(CreateSession()));
Assert.Contains("did not contain a 'data' field", ex.Message);
}
[Fact]
public void GetClusterConfig_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterConfig(null!));
}
[Fact]
public void GetConfigNodes_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetConfigNodes(null!));
}
[Fact]
public void GetClusterOptions_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterOptions(null!));
}
[Fact]
public void GetNextId_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetNextId(null!));
}
[Fact]
public void GetClusterStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new ClusterConfigService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetClusterStatus(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new ClusterConfigService(null!));
}
}
}
@@ -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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "started",
["group"] = "grp1"
};
// Act
service.CreateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/resources",
It.Is<Dictionary<string, string>>(d =>
d["sid"] == "vm:100" &&
d["state"] == "started" &&
d["group"] == "grp1")),
Times.Once);
}
[Fact]
public void UpdateResource_PutsOptionsToEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
mockClient.Setup(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["state"] = "stopped"
};
// Act
service.UpdateResource(CreateSession(), "vm:100", options);
// Assert
mockClient.Verify(c => c.PutAsync(
"cluster/ha/resources/vm%3A100",
It.Is<Dictionary<string, string>>(d =>
d["state"] == "stopped")),
Times.Once);
}
[Fact]
public void DeleteResource_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/migrate",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/resources/vm%3A100/relocate",
It.IsAny<Dictionary<string, string>>()))
.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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/groups",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["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<Dictionary<string, string>>(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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
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<IPveHttpClient>();
mockClient.Setup(c => c.PostAsync(
"cluster/ha/rules",
It.IsAny<Dictionary<string, string>>()))
.ReturnsAsync("{}");
var service = new HaService(mockClient.Object);
var options = new Dictionary<string, string>
{
["rule"] = "rule1",
["type"] = "location",
["state"] = "enabled"
};
// Act
service.CreateRule(CreateSession(), options);
// Assert
mockClient.Verify(c => c.PostAsync(
"cluster/ha/rules",
It.Is<Dictionary<string, string>>(d =>
d["rule"] == "rule1" &&
d["type"] == "location" &&
d["state"] == "enabled")),
Times.Once);
}
[Fact]
public void DeleteRule_CallsDeleteWithEncodedUrl()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
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<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetResources(null!));
}
[Fact]
public void GetGroups_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetGroups(null!));
}
[Fact]
public void GetStatus_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetStatus(null!));
}
[Fact]
public void GetRules_NullSession_ThrowsArgumentNullException()
{
// Arrange
var mockClient = new Mock<IPveHttpClient>();
var service = new HaService(mockClient.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() => service.GetRules(null!));
}
[Fact]
public void Constructor_NullClient_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => new HaService(null!));
}
}
}
@@ -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*'
}
}
}
@@ -0,0 +1,822 @@
#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 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' -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*'
}
}
}
@@ -0,0 +1,445 @@
#Requires -Module Pester
<#
.SYNOPSIS
Pester 5 integration tests for cluster configuration and HA lifecycle.
These tests exercise the full cluster lifecycle: creating a cluster on
node A, joining node B, managing HA groups, and removing node B.
Nodes are provisioned STANDALONE (not clustered). The tests create and
destroy the cluster during the run.
Required environment variables:
PVETEST_HOST - Node A hostname or IP
PVETEST_PORT - API port (default 8006)
PVETEST_APITOKEN - Node A API token
PVETEST_NODE - Node A node name
Optional (multi-node tests):
PVETEST_HOST_B - Node B hostname or IP
PVETEST_APITOKEN_B - Node B API token
PVETEST_PASSWORD - Root password for nested PVE instances
Optional:
PVETEST_PVE_VERSION - PVE major version (8 or 9)
WARNING: These tests CREATE and DESTROY a cluster on the target nodes.
Never run against a production cluster.
#>
BeforeAll {
. $PSScriptRoot/../_TestHelper.ps1
# --- Required env vars ---
$requiredVars = @('PVETEST_HOST', 'PVETEST_PORT', 'PVETEST_APITOKEN', 'PVETEST_NODE')
$script:SkipReason = $null
foreach ($var in $requiredVars) {
if (-not [System.Environment]::GetEnvironmentVariable($var)) {
$script:SkipReason = "No live Proxmox VE target configured. Set: $($requiredVars -join ', ')"
break
}
}
# --- Convenience accessors ---
$script:Host_ = [System.Environment]::GetEnvironmentVariable('PVETEST_HOST')
$portEnv = [System.Environment]::GetEnvironmentVariable('PVETEST_PORT')
$script:Port = [int]$(if ($portEnv) { $portEnv } else { '8006' })
$script:ApiToken = [System.Environment]::GetEnvironmentVariable('PVETEST_APITOKEN')
$script:Node = [System.Environment]::GetEnvironmentVariable('PVETEST_NODE')
# --- Optional multi-node vars ---
$script:HostB = [System.Environment]::GetEnvironmentVariable('PVETEST_HOST_B')
$script:ApiTokenB = [System.Environment]::GetEnvironmentVariable('PVETEST_APITOKEN_B')
$script:Password = [System.Environment]::GetEnvironmentVariable('PVETEST_PASSWORD')
$script:PveVersion = [System.Environment]::GetEnvironmentVariable('PVETEST_PVE_VERSION')
# --- State tracking ---
$script:ClusterCreated = $false
$script:JoinInfo = $null
$script:NodeBName = $null
# --- Skip helpers ---
function script:Skip-IfNoTarget {
if ($script:SkipReason) {
Set-ItResult -Skipped -Because $script:SkipReason
return $true
}
return $false
}
function script:Skip-IfNoNodeB {
if (Skip-IfNoTarget) { return $true }
if (-not $script:HostB -or -not $script:ApiTokenB -or -not $script:Password) {
Set-ItResult -Skipped -Because 'Multi-node env vars not set (PVETEST_HOST_B, PVETEST_APITOKEN_B, PVETEST_PASSWORD)'
return $true
}
return $false
}
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-IfNoPve9 {
if (Skip-IfNoCluster) { return $true }
if (-not $script:PveVersion -or [int]$script:PveVersion -lt 9) {
Set-ItResult -Skipped -Because 'HA rules require PVE 9.0+'
return $true
}
return $false
}
}
AfterAll {
# Best-effort cleanup — each step may fail if cluster state is partial
if ($script:ClusterCreated) {
# Try to discover node B name and remove it from the cluster
if ($script:NodeBName) {
try {
Write-Warning "Cleanup: removing node '$($script:NodeBName)' from cluster..."
Remove-PveClusterConfigNode -Node $script:NodeBName -Confirm:$false -ErrorAction Stop
}
catch {
Write-Warning "Cleanup: failed to remove node B from cluster: $_"
}
}
}
# Disconnect any active sessions
try { Disconnect-PveServer -ErrorAction SilentlyContinue } catch { }
}
Describe 'Cluster Config & HA Lifecycle — Integration' -Tag 'Integration' {
# -------------------------------------------------------------------
Context 'Connection' {
It 'Should connect to PVE node A' {
if (Skip-IfNoTarget) { return }
$session = Connect-PveServer `
-Server $script:Host_ `
-Port $script:Port `
-ApiToken $script:ApiToken `
-SkipCertificateCheck `
-PassThru
$session | Should -Not -BeNullOrEmpty
}
}
# -------------------------------------------------------------------
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 }
# /cluster/config returns a directory listing (array) on standalone nodes
# and may return an object on clustered nodes — either is valid
{ Get-PveClusterConfig -ErrorAction Stop } | Should -Not -Throw
}
It 'Get-PveHaStatus returns status without throwing' {
if (Skip-IfNoTarget) { return }
# On a standalone node this may return an empty list — that is fine
{ 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 }
# Cluster creation requires root@pam ticket auth, not API token
$secPw = ConvertTo-SecureString $script:Password -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('root@pam', $secPw)
Connect-PveServer `
-Server $script:Host_ `
-Port $script:Port `
-Credential $cred `
-SkipCertificateCheck
$result = New-PveCluster -ClusterName 'pester-cluster' -Confirm:$false -ErrorAction Stop
$result | Should -Not -BeNullOrEmpty
$script:ClusterCreated = $true
# Wait for corosync to settle
Start-Sleep -Seconds 5
# Reconnect with API token for remaining tests
Connect-PveServer `
-Server $script:Host_ `
-Port $script:Port `
-ApiToken $script:ApiToken `
-SkipCertificateCheck
}
It 'Get-PveClusterStatus shows cluster formed' {
if (Skip-IfNoCluster) { return }
$status = Get-PveClusterStatus -ErrorAction Stop
$clusterEntry = @($status) | Where-Object { $_.Type -eq 'cluster' }
$clusterEntry | Should -Not -BeNullOrEmpty
}
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
$script:JoinInfo.Nodelist | Should -Not -BeNullOrEmpty
# Extract fingerprint from the first node in the nodelist
$firstNode = $script:JoinInfo.Nodelist[0]
$firstNode['pve_fp'] | Should -Not -BeNullOrEmpty
}
It 'Add-PveClusterMember joins node B to cluster' {
if (Skip-IfNoNodeB) { return }
if (Skip-IfNoCluster) { return }
if ($null -eq $script:JoinInfo) {
Set-ItResult -Skipped -Because 'Join info was not retrieved'
return
}
# Connect to node B with root@pam (join requires ticket auth, not API token)
$secPw = ConvertTo-SecureString $script:Password -AsPlainText -Force
$credB = New-Object System.Management.Automation.PSCredential('root@pam', $secPw)
Connect-PveServer `
-Server $script:HostB `
-Port $script:Port `
-Credential $credB `
-SkipCertificateCheck
# Extract fingerprint from join info
$fingerprint = $script:JoinInfo.Nodelist[0]['pve_fp'].ToString()
$result = Add-PveClusterMember `
-Hostname $script:Host_ `
-Fingerprint $fingerprint `
-Password $secPw `
-Confirm:$false `
-ErrorAction Stop
$result | Should -Not -BeNullOrEmpty
# Wait for cluster sync
Start-Sleep -Seconds 15
# Reconnect to node A for subsequent tests
Connect-PveServer `
-Server $script:Host_ `
-Port $script:Port `
-ApiToken $script:ApiToken `
-SkipCertificateCheck
}
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 for cleanup
$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 }
# Save original keyboard setting
$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 {
# Original was unset — delete the key to restore default
Set-PveClusterOption -Delete 'keyboard' -Confirm:$false -ErrorAction Stop
}
$options = Get-PveClusterOption -ErrorAction Stop
$options.Keyboard | Should -Be $script:OriginalKeyboard
}
}
# -------------------------------------------------------------------
Context 'HA group management' {
It 'New-PveHaGroup creates test group' {
if (Skip-IfNoCluster) { 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-IfNoCluster) { 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-IfNoCluster) { 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-IfNoCluster) { return }
{ Set-PveHaGroup -Group 'pester-group' -Comment 'pester test' -Confirm:$false -ErrorAction Stop } |
Should -Not -Throw
$group = Get-PveHaGroup -Group 'pester-group' -ErrorAction Stop
$group.Comment | Should -Be 'pester test'
}
It 'Remove-PveHaGroup deletes test group' {
if (Skip-IfNoCluster) { return }
{ Remove-PveHaGroup -Group 'pester-group' -Confirm:$false -ErrorAction Stop } |
Should -Not -Throw
# Verify the group is gone
$groups = @(Get-PveHaGroup -ErrorAction Stop)
$match = $groups | Where-Object { $_.Group -eq 'pester-group' }
$match | Should -BeNullOrEmpty
}
}
# -------------------------------------------------------------------
Context 'HA resource management' {
It 'Skipped — HA resources require a test VM' {
Set-ItResult -Skipped -Because 'HA resource tests require a running VM, which is not provisioned in this test file'
}
}
# -------------------------------------------------------------------
Context 'HA rules (PVE 9.0+)' {
It 'Get-PveHaRule returns rules list without throwing' {
if (Skip-IfNoPve9) { return }
# May return an empty list on a fresh cluster — that is fine
{ Get-PveHaRule -ErrorAction Stop } | Should -Not -Throw
}
}
# -------------------------------------------------------------------
Context 'Remove node B and cleanup' {
It 'Remove-PveClusterConfigNode removes node B' {
if (Skip-IfNoNodeB) { return }
if (Skip-IfNoCluster) { return }
if (-not $script:NodeBName) {
Set-ItResult -Skipped -Because 'Node B name was not discovered'
return
}
# Ensure we are connected to node A
Connect-PveServer `
-Server $script:Host_ `
-Port $script:Port `
-ApiToken $script:ApiToken `
-SkipCertificateCheck
{ Remove-PveClusterConfigNode -Node $script:NodeBName -Confirm:$false -ErrorAction Stop } |
Should -Not -Throw
# Clear the name so AfterAll does not try to remove again
$script:NodeBName = $null
# Wait for config to propagate
Start-Sleep -Seconds 5
}
It 'Get-PveClusterConfigNode shows only node A' {
if (Skip-IfNoNodeB) { return }
if (Skip-IfNoCluster) { return }
$nodes = Get-PveClusterConfigNode -ErrorAction Stop
@($nodes).Count | Should -Be 1
@($nodes)[0].Name | Should -Be $script:Node
}
}
}