refactor: route the network and SDN cmdlets through NetworkService (#126) (#201)

Wires the 12 direct-PveHttpClient Network/SDN cmdlets (Get/New/Set/Remove-PveNetwork,
Invoke-PveNetworkApply, Get/New-PveSdnZone, Get/New-PveSdnVnet, Get/New-PveSdnSubnet,
Remove-PveSdnSubnet) to the existing NetworkService methods, which previously had zero
callers for the New/Set/Get paths. NetworkService.ParseTask now stamps
Status = "running" on the UPID-string branch, matching SnapshotService's ParseTask from
PR #196 (the pattern PR). Offline tests for every wired service method are added to
NetworkServiceTests.cs.

Fold-in from issue #126's own comment: ValidatePattern identifier validation, already
present on Remove-PveSdnZone/Vnet and Remove-PveStorage from PR #161, is added to the
Zone/Vnet identifier parameters on the New-PveSdnZone/Vnet/Subnet and
Remove-PveSdnSubnet cmdlets converted here.

Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com>
This commit is contained in:
goodolclint-claude[bot]
2026-09-03 00:14:40 +00:00
committed by GitHub
parent 3f7f69b250
commit 87fa7a7a9c
14 changed files with 673 additions and 99 deletions
@@ -597,7 +597,7 @@ namespace PSProxmoxVE.Core.Services
{ {
var data = JObject.Parse(response)["data"]; var data = JObject.Parse(response)["data"];
if (data?.Type == JTokenType.String) if (data?.Type == JTokenType.String)
return new PveTask { Upid = data.ToString(), Node = node }; return new PveTask { Upid = data.ToString(), Node = node, Status = "running" };
var task = data?.ToObject<PveTask>() ?? new PveTask(); var task = data?.ToObject<PveTask>() ?? new PveTask();
task.Node = node; task.Node = node;
@@ -1,8 +1,6 @@
using System;
using System.Management.Automation; using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Network; using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -37,20 +35,13 @@ namespace PSProxmoxVE.Cmdlets.Network
protected override void ProcessRecord() protected override void ProcessRecord()
{ {
var session = GetSession(); var session = GetSession();
using var client = new PveHttpClient(session);
WriteVerbose($"Getting network interfaces on node '{Node}'..."); WriteVerbose($"Getting network interfaces on node '{Node}'...");
var resource = $"nodes/{Uri.EscapeDataString(Node)}/network"; var service = new NetworkService();
if (!string.IsNullOrEmpty(Type)) var networks = service.GetNetworks(session, Node, Type);
resource += $"?type={Uri.EscapeDataString(Type)}";
var json = client.GetAsync(resource).GetAwaiter().GetResult(); foreach (var network in networks)
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
foreach (var item in data)
{ {
var network = item.ToObject<PveNetwork>()!;
network.Node = Node; network.Node = Node;
if (!string.IsNullOrEmpty(Iface) && if (!string.IsNullOrEmpty(Iface) &&
!string.Equals(network.Iface, Iface, System.StringComparison.OrdinalIgnoreCase)) !string.Equals(network.Iface, Iface, System.StringComparison.OrdinalIgnoreCase))
@@ -1,7 +1,6 @@
using System.Management.Automation; using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Network; using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -28,18 +27,13 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Getting SDN subnets for VNet '{Vnet}'..."); WriteVerbose($"Getting SDN subnets for VNet '{Vnet}'...");
var json = client.GetAsync($"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets") var service = new NetworkService();
.GetAwaiter().GetResult(); var subnets = service.GetSdnSubnets(session, Vnet);
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
foreach (var item in data) foreach (var subnet in subnets)
{ {
var subnet = item.ToObject<PveSdnSubnet>()!;
if (!string.IsNullOrEmpty(Subnet) && if (!string.IsNullOrEmpty(Subnet) &&
!string.Equals(subnet.Subnet, Subnet, System.StringComparison.OrdinalIgnoreCase)) !string.Equals(subnet.Subnet, Subnet, System.StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -1,7 +1,6 @@
using System.Management.Automation; using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Network; using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -28,17 +27,13 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose("Getting SDN VNets..."); WriteVerbose("Getting SDN VNets...");
var json = client.GetAsync("cluster/sdn/vnets").GetAwaiter().GetResult(); var service = new NetworkService();
var root = JObject.Parse(json); var vnets = service.GetSdnVnets(session);
var data = root["data"] as JArray ?? new JArray();
foreach (var item in data) foreach (var vnet in vnets)
{ {
var vnet = item.ToObject<PveSdnVnet>()!;
if (!string.IsNullOrEmpty(Zone) && if (!string.IsNullOrEmpty(Zone) &&
!string.Equals(vnet.Zone, Zone, System.StringComparison.OrdinalIgnoreCase)) !string.Equals(vnet.Zone, Zone, System.StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -1,7 +1,6 @@
using System.Management.Automation; using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Network; using PSProxmoxVE.Core.Models.Network;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -23,17 +22,13 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose("Getting SDN zones..."); WriteVerbose("Getting SDN zones...");
var resource = "cluster/sdn/zones"; var service = new NetworkService();
var json = client.GetAsync(resource).GetAwaiter().GetResult(); var zones = service.GetSdnZones(session);
var root = JObject.Parse(json);
var data = root["data"] as JArray ?? new JArray();
foreach (var item in data) foreach (var zone in zones)
{ {
var zone = item.ToObject<PveSdnZone>()!;
if (!string.IsNullOrEmpty(Zone) && if (!string.IsNullOrEmpty(Zone) &&
!string.Equals(zone.Zone, Zone, System.StringComparison.OrdinalIgnoreCase)) !string.Equals(zone.Zone, Zone, System.StringComparison.OrdinalIgnoreCase))
continue; continue;
@@ -1,7 +1,4 @@
using System;
using System.Management.Automation; using System.Management.Automation;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Models.Vms;
using PSProxmoxVE.Core.Services; using PSProxmoxVE.Core.Services;
@@ -33,19 +30,15 @@ namespace PSProxmoxVE.Cmdlets.Network
return; return;
var session = GetSession(); var session = GetSession();
using var client = new PveHttpClient(session);
WriteVerbose($"Applying network configuration on node '{Node}'..."); WriteVerbose($"Applying network configuration on node '{Node}'...");
var json = client.PutAsync($"nodes/{Uri.EscapeDataString(Node)}/network").GetAwaiter().GetResult(); var service = new NetworkService();
var root = JObject.Parse(json); var task = service.ApplyNetworkConfig(session, Node);
var upid = root["data"]?.ToString() ?? string.Empty;
var task = new PveTask { Upid = upid, Node = Node, Status = "running" }; if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid))
if (Wait.IsPresent && !string.IsNullOrEmpty(upid))
{ {
var taskService = new TaskService(); var taskService = new TaskService();
task = taskService.WaitForTask(session, Node, upid); task = taskService.WaitForTask(session, Node, task.Upid);
} }
WriteObject(task); WriteObject(task);
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -76,10 +75,9 @@ namespace PSProxmoxVE.Cmdlets.Network
return; return;
var session = GetSession(); var session = GetSession();
using var client = new PveHttpClient(session);
WriteVerbose($"Creating network interface '{Iface}' on node '{Node}'..."); WriteVerbose($"Creating network interface '{Iface}' on node '{Node}'...");
var data = new Dictionary<string, string> var data = new Dictionary<string, object>
{ {
["iface"] = Iface, ["iface"] = Iface,
["type"] = Type ["type"] = Type
@@ -96,7 +94,8 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1"; if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!; if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!;
client.PostAsync($"nodes/{Uri.EscapeDataString(Node)}/network", data).GetAwaiter().GetResult(); var service = new NetworkService();
service.CreateNetwork(session, Node, data);
} }
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -17,6 +17,7 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
/// <summary>The SDN VNet to add the subnet to.</summary> /// <summary>The SDN VNet to add the subnet to.</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")] [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")]
[ValidatePattern(@"\A[A-Za-z0-9][A-Za-z0-9._-]*\z")]
public string Vnet { get; set; } = string.Empty; public string Vnet { get; set; } = string.Empty;
/// <summary>The subnet CIDR notation (e.g. "10.0.0.0/24").</summary> /// <summary>The subnet CIDR notation (e.g. "10.0.0.0/24").</summary>
@@ -54,10 +55,8 @@ namespace PSProxmoxVE.Cmdlets.Network
+ $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored."); + $"Connected server is PVE {session.ServerVersion}. The parameter will be sent but may be ignored.");
} }
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'..."); WriteVerbose($"Creating SDN subnet '{Subnet}' on VNet '{Vnet}'...");
var data = new Dictionary<string, string> var data = new Dictionary<string, object>
{ {
["subnet"] = Subnet, ["subnet"] = Subnet,
["type"] = "subnet" ["type"] = "subnet"
@@ -68,9 +67,8 @@ namespace PSProxmoxVE.Cmdlets.Network
if (!string.IsNullOrEmpty(DnsZonePrefix)) data["dnszoneprefix"] = DnsZonePrefix!; if (!string.IsNullOrEmpty(DnsZonePrefix)) data["dnszoneprefix"] = DnsZonePrefix!;
if (!string.IsNullOrEmpty(DhcpRange)) data["dhcp-range"] = DhcpRange!; if (!string.IsNullOrEmpty(DhcpRange)) data["dhcp-range"] = DhcpRange!;
client.PostAsync( var service = new NetworkService();
$"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets", data) service.CreateSdnSubnet(session, Vnet, data);
.GetAwaiter().GetResult();
} }
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -16,10 +16,12 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
/// <summary>The VNet identifier (alphanumeric, up to 8 characters).</summary> /// <summary>The VNet identifier (alphanumeric, up to 8 characters).</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")] [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")]
[ValidatePattern(@"\A[A-Za-z0-9][A-Za-z0-9._-]*\z")]
public string Vnet { get; set; } = string.Empty; public string Vnet { get; set; } = string.Empty;
/// <summary>The SDN zone this VNet belongs to.</summary> /// <summary>The SDN zone this VNet belongs to.</summary>
[Parameter(Mandatory = true, Position = 1, HelpMessage = "The SDN zone name.")] [Parameter(Mandatory = true, Position = 1, HelpMessage = "The SDN zone name.")]
[ValidatePattern(@"\A[A-Za-z0-9][A-Za-z0-9._-]*\z")]
public string Zone { get; set; } = string.Empty; public string Zone { get; set; } = string.Empty;
/// <summary>VLAN tag for VLAN-type zones.</summary> /// <summary>VLAN tag for VLAN-type zones.</summary>
@@ -41,10 +43,9 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN VNet '{Vnet}'..."); WriteVerbose($"Creating SDN VNet '{Vnet}'...");
var data = new Dictionary<string, string> var data = new Dictionary<string, object>
{ {
["vnet"] = Vnet, ["vnet"] = Vnet,
["zone"] = Zone ["zone"] = Zone
@@ -54,7 +55,8 @@ namespace PSProxmoxVE.Cmdlets.Network
if (!string.IsNullOrEmpty(Alias)) data["alias"] = Alias!; if (!string.IsNullOrEmpty(Alias)) data["alias"] = Alias!;
if (VlanAware.IsPresent) data["vlanaware"] = "1"; if (VlanAware.IsPresent) data["vlanaware"] = "1";
client.PostAsync("cluster/sdn/vnets", data).GetAwaiter().GetResult(); var service = new NetworkService();
service.CreateSdnVnet(session, data);
} }
} }
} }
@@ -1,6 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -16,6 +16,7 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
/// <summary>The zone identifier (alphanumeric, hyphens allowed).</summary> /// <summary>The zone identifier (alphanumeric, hyphens allowed).</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN zone name.")] [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN zone name.")]
[ValidatePattern(@"\A[A-Za-z0-9][A-Za-z0-9._-]*\z")]
public string Zone { get; set; } = string.Empty; public string Zone { get; set; } = string.Empty;
/// <summary>The zone type.</summary> /// <summary>The zone type.</summary>
@@ -58,10 +59,9 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Creating SDN zone '{Zone}'..."); WriteVerbose($"Creating SDN zone '{Zone}'...");
var data = new Dictionary<string, string> var data = new Dictionary<string, object>
{ {
["zone"] = Zone, ["zone"] = Zone,
["type"] = Type ["type"] = Type
@@ -75,7 +75,8 @@ namespace PSProxmoxVE.Cmdlets.Network
if (!string.IsNullOrEmpty(DnsZone)) data["dnszone"] = DnsZone!; if (!string.IsNullOrEmpty(DnsZone)) data["dnszone"] = DnsZone!;
if (!string.IsNullOrEmpty(Ipam)) data["ipam"] = Ipam!; if (!string.IsNullOrEmpty(Ipam)) data["ipam"] = Ipam!;
client.PostAsync("cluster/sdn/zones", data).GetAwaiter().GetResult(); var service = new NetworkService();
service.CreateSdnZone(session, data);
} }
} }
} }
@@ -1,6 +1,5 @@
using System;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -29,10 +28,10 @@ namespace PSProxmoxVE.Cmdlets.Network
return; return;
var session = GetSession(); var session = GetSession();
using var client = new PveHttpClient(session);
WriteVerbose($"Removing network interface '{Iface}' on node '{Node}'..."); WriteVerbose($"Removing network interface '{Iface}' from node '{Node}'...");
client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/network/{Uri.EscapeDataString(Iface)}").GetAwaiter().GetResult(); var service = new NetworkService();
service.RemoveNetwork(session, Node, Iface);
} }
} }
} }
@@ -1,5 +1,5 @@
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -16,6 +16,7 @@ namespace PSProxmoxVE.Cmdlets.Network
{ {
/// <summary>The SDN VNet containing the subnet.</summary> /// <summary>The SDN VNet containing the subnet.</summary>
[Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")] [Parameter(Mandatory = true, Position = 0, HelpMessage = "The SDN VNet name.")]
[ValidatePattern(@"\A[A-Za-z0-9][A-Za-z0-9._-]*\z")]
public string Vnet { get; set; } = string.Empty; public string Vnet { get; set; } = string.Empty;
/// <summary>The subnet CIDR to remove (e.g. "10.0.0.0/24").</summary> /// <summary>The subnet CIDR to remove (e.g. "10.0.0.0/24").</summary>
@@ -29,12 +30,10 @@ namespace PSProxmoxVE.Cmdlets.Network
var session = GetSession(); var session = GetSession();
RequireVersion(session, "SDN", 6, 2, 8, 0); RequireVersion(session, "SDN", 6, 2, 8, 0);
using var client = new PveHttpClient(session);
WriteVerbose($"Removing SDN subnet '{Subnet}' from VNet '{Vnet}'..."); WriteVerbose($"Removing SDN subnet '{Subnet}' from VNet '{Vnet}'...");
client.DeleteAsync( var service = new NetworkService();
$"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets/{System.Uri.EscapeDataString(Subnet)}") service.RemoveSdnSubnet(session, Vnet, Subnet);
.GetAwaiter().GetResult();
} }
} }
} }
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Management.Automation; using System.Management.Automation;
using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Network namespace PSProxmoxVE.Cmdlets.Network
{ {
@@ -82,10 +81,9 @@ namespace PSProxmoxVE.Cmdlets.Network
return; return;
var session = GetSession(); var session = GetSession();
using var client = new PveHttpClient(session);
WriteVerbose($"Updating network interface '{Iface}' on node '{Node}'..."); WriteVerbose($"Updating network interface '{Iface}' on node '{Node}'...");
var data = new Dictionary<string, string> var data = new Dictionary<string, object>
{ {
["type"] = Type ["type"] = Type
}; };
@@ -109,7 +107,8 @@ namespace PSProxmoxVE.Cmdlets.Network
if (Autostart.IsPresent) data["autostart"] = "1"; if (Autostart.IsPresent) data["autostart"] = "1";
if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!; if (!string.IsNullOrEmpty(Comments)) data["comments"] = Comments!;
client.PutAsync($"nodes/{Uri.EscapeDataString(Node)}/network/{Uri.EscapeDataString(Iface)}", data).GetAwaiter().GetResult(); var service = new NetworkService();
service.SetNetwork(session, Node, Iface, data);
} }
} }
} }
@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using Moq; using Moq;
using Xunit; using Xunit;
using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Authentication;
@@ -12,6 +14,10 @@ namespace PSProxmoxVE.Core.Tests.Services
private readonly NetworkService _service; private readonly NetworkService _service;
private readonly PveSession _session; private readonly PveSession _session;
private const string Node = "pve1";
private const string Vnet = "vnet1";
private const string ApplyUpid = "UPID:pve1:000ABC:00000001:5F1234AB:srvreload:root@pam:";
public NetworkServiceTests() public NetworkServiceTests()
{ {
_mockClient = new Mock<IPveHttpClient>(); _mockClient = new Mock<IPveHttpClient>();
@@ -23,39 +29,642 @@ namespace PSProxmoxVE.Core.Tests.Services
apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); apiToken: "root@pam!test=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
} }
private sealed class CapturedRequest
{
public int Calls { get; set; }
public string? Path { get; set; }
public Dictionary<string, string>? Form { get; set; }
}
private static string UpidJson(string upid) => $@"{{""data"": ""{upid}""}}";
private CapturedRequest CapturePost(string json)
{
var captured = new CapturedRequest();
_mockClient.Setup(c => c.PostAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
.Callback<string, Dictionary<string, string>?>((path, form) =>
{
captured.Calls++;
captured.Path = path;
captured.Form = form;
})
.ReturnsAsync(json);
return captured;
}
private CapturedRequest CapturePut(string json)
{
var captured = new CapturedRequest();
_mockClient.Setup(c => c.PutAsync(It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()))
.Callback<string, Dictionary<string, string>?>((path, form) =>
{
captured.Calls++;
captured.Path = path;
captured.Form = form;
})
.ReturnsAsync(json);
return captured;
}
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// RemoveSdnZone // GetNetworks
// -----------------------------------------------------------------
[Fact]
public void GetNetworks_NoType_RequestsPlainPath()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": [{""iface"": ""vmbr0"", ""type"": ""bridge""}]}");
var networks = _service.GetNetworks(_session, Node);
Assert.Single(networks);
Assert.Equal("vmbr0", networks[0].Iface);
_mockClient.Verify(c => c.GetAsync($"nodes/{Node}/network"), Times.Once);
}
[Fact]
public void GetNetworks_WithType_AppendsQueryString()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": []}");
_service.GetNetworks(_session, Node, "bridge");
_mockClient.Verify(c => c.GetAsync($"nodes/{Node}/network?type=bridge"), Times.Once);
}
[Fact]
public void GetNetworks_EscapesNodeInPath()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": []}");
_service.GetNetworks(_session, "pve node");
_mockClient.Verify(c => c.GetAsync("nodes/pve%20node/network"), Times.Once);
}
[Fact]
public void GetNetworks_EmptyData_ReturnsEmptyArray()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": []}");
var networks = _service.GetNetworks(_session, Node);
Assert.Empty(networks);
}
[Fact]
public void GetNetworks_NullData_ReturnsEmptyArray()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": null}");
var networks = _service.GetNetworks(_session, Node);
Assert.Empty(networks);
}
[Fact]
public void GetNetworks_EmptyTypeString_OmitsQueryString()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": []}");
_service.GetNetworks(_session, Node, string.Empty);
_mockClient.Verify(c => c.GetAsync($"nodes/{Node}/network"), Times.Once);
}
[Fact]
public void GetNetworks_EscapesTypeInQueryString()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>()))
.ReturnsAsync(@"{""data"": []}");
_service.GetNetworks(_session, Node, "any bridge");
_mockClient.Verify(c => c.GetAsync($"nodes/{Node}/network?type=any%20bridge"), Times.Once);
}
[Fact]
public void GetNetworks_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.GetNetworks(null!, Node));
}
[Fact]
public void GetNetworks_WhitespaceNode_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("node", () => _service.GetNetworks(_session, " "));
}
// -----------------------------------------------------------------
// CreateNetwork
// -----------------------------------------------------------------
[Fact]
public void CreateNetwork_RequiredFieldsOnly_SendsExactForm()
{
var captured = CapturePost(@"{""data"": {""iface"": ""vmbr1"", ""type"": ""bridge""}}");
var config = new Dictionary<string, object> { ["iface"] = "vmbr1", ["type"] = "bridge" };
var result = _service.CreateNetwork(_session, Node, config);
Assert.Equal(1, captured.Calls);
Assert.Equal($"nodes/{Node}/network", captured.Path);
Assert.NotNull(captured.Form);
Assert.Equal("vmbr1", captured.Form!["iface"]);
Assert.Equal("bridge", captured.Form["type"]);
Assert.Equal(2, captured.Form.Count);
Assert.Equal("vmbr1", result.Iface);
}
[Fact]
public void CreateNetwork_NonStringConfigValues_AreStringified()
{
var captured = CapturePost(@"{""data"": {}}");
var config = new Dictionary<string, object>
{
["iface"] = "vmbr1",
["type"] = "bridge",
["mtu"] = 9000,
["comments"] = null!
};
_service.CreateNetwork(_session, Node, config);
Assert.Equal("9000", captured.Form!["mtu"]);
Assert.Equal(string.Empty, captured.Form["comments"]);
}
[Fact]
public void CreateNetwork_AllFields_SendsExactForm()
{
var captured = CapturePost(@"{""data"": {""iface"": ""vmbr1"", ""type"": ""bridge""}}");
var config = new Dictionary<string, object>
{
["iface"] = "vmbr1",
["type"] = "bridge",
["address"] = "10.0.0.1",
["netmask"] = "255.255.255.0",
["gateway"] = "10.0.0.254",
["bridge_ports"] = "eth0",
["bridge_vlan_aware"] = "1",
["mtu"] = "9000",
["autostart"] = "1",
["comments"] = "test bridge"
};
_service.CreateNetwork(_session, Node, config);
Assert.Equal(10, captured.Form!.Count);
Assert.Equal("10.0.0.1", captured.Form["address"]);
Assert.Equal("255.255.255.0", captured.Form["netmask"]);
Assert.Equal("10.0.0.254", captured.Form["gateway"]);
Assert.Equal("eth0", captured.Form["bridge_ports"]);
Assert.Equal("1", captured.Form["bridge_vlan_aware"]);
Assert.Equal("9000", captured.Form["mtu"]);
Assert.Equal("1", captured.Form["autostart"]);
Assert.Equal("test bridge", captured.Form["comments"]);
}
[Fact]
public void CreateNetwork_EscapesNodeInPath()
{
var captured = CapturePost(@"{""data"": {}}");
var config = new Dictionary<string, object> { ["iface"] = "vmbr1", ["type"] = "bridge" };
_service.CreateNetwork(_session, "pve node", config);
Assert.Equal(1, captured.Calls);
Assert.Equal("nodes/pve%20node/network", captured.Path);
}
[Fact]
public void CreateNetwork_NullSession_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["iface"] = "vmbr1" };
Assert.Throws<ArgumentNullException>("session", () => _service.CreateNetwork(null!, Node, config));
}
[Fact]
public void CreateNetwork_NullConfig_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("config", () => _service.CreateNetwork(_session, Node, null!));
}
// -----------------------------------------------------------------
// SetNetwork
// -----------------------------------------------------------------
[Fact]
public void SetNetwork_SendsConfigAsFormAgainstIfacePath()
{
var captured = CapturePut(@"{""data"": null}");
var config = new Dictionary<string, object> { ["type"] = "bridge" };
_service.SetNetwork(_session, Node, "vmbr0", config);
Assert.Equal(1, captured.Calls);
Assert.Equal($"nodes/{Node}/network/vmbr0", captured.Path);
Assert.Equal("bridge", captured.Form!["type"]);
Assert.Single(captured.Form);
}
[Fact]
public void SetNetwork_DeleteKey_IsSentVerbatim()
{
var captured = CapturePut(@"{""data"": null}");
var config = new Dictionary<string, object> { ["type"] = "bridge", ["delete"] = "bridge_vlan_aware" };
_service.SetNetwork(_session, Node, "vmbr0", config);
Assert.Equal("bridge_vlan_aware", captured.Form!["delete"]);
Assert.False(captured.Form.ContainsKey("bridge_vlan_aware"));
Assert.Equal(2, captured.Form.Count);
}
[Fact]
public void SetNetwork_EscapesNodeAndIfaceInPath()
{
var captured = CapturePut(@"{""data"": null}");
var config = new Dictionary<string, object> { ["type"] = "bridge" };
_service.SetNetwork(_session, "pve node", "vmbr 0", config);
Assert.Equal(1, captured.Calls);
Assert.Equal("nodes/pve%20node/network/vmbr%200", captured.Path);
}
[Fact]
public void SetNetwork_NullSession_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["type"] = "bridge" };
Assert.Throws<ArgumentNullException>("session", () => _service.SetNetwork(null!, Node, "vmbr0", config));
}
[Fact]
public void SetNetwork_WhitespaceIface_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["type"] = "bridge" };
Assert.Throws<ArgumentNullException>("iface", () => _service.SetNetwork(_session, Node, " ", config));
}
// -----------------------------------------------------------------
// RemoveNetwork
// -----------------------------------------------------------------
[Fact]
public void RemoveNetwork_CallsDeleteAsync()
{
_mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>())).ReturnsAsync(@"{""data"": null}");
_service.RemoveNetwork(_session, Node, "vmbr1");
_mockClient.Verify(c => c.DeleteAsync($"nodes/{Node}/network/vmbr1"), Times.Once);
_mockClient.VerifyNoOtherCalls();
}
[Fact]
public void RemoveNetwork_EscapesNodeAndIfaceInPath()
{
_mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>())).ReturnsAsync(@"{""data"": null}");
_service.RemoveNetwork(_session, "pve node", "vmbr 1");
_mockClient.Verify(c => c.DeleteAsync("nodes/pve%20node/network/vmbr%201"), Times.Once);
_mockClient.VerifyNoOtherCalls();
}
[Fact]
public void RemoveNetwork_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.RemoveNetwork(null!, Node, "vmbr1"));
}
// -----------------------------------------------------------------
// ApplyNetworkConfig
// -----------------------------------------------------------------
[Fact]
public void ApplyNetworkConfig_CallsPutAsyncWithNoBody_ReturnsRunningTask()
{
_mockClient.Setup(c => c.PutAsync($"nodes/{Node}/network", null))
.ReturnsAsync(UpidJson(ApplyUpid));
var task = _service.ApplyNetworkConfig(_session, Node);
Assert.Equal(ApplyUpid, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Equal("running", task.Status);
_mockClient.Verify(c => c.PutAsync($"nodes/{Node}/network", null), Times.Once);
}
[Fact]
public void ApplyNetworkConfig_EscapesNodeInPath()
{
_mockClient.Setup(c => c.PutAsync("nodes/pve%20node/network", null))
.ReturnsAsync(UpidJson(ApplyUpid));
_service.ApplyNetworkConfig(_session, "pve node");
_mockClient.Verify(c => c.PutAsync("nodes/pve%20node/network", null), Times.Once);
}
[Fact]
public void ApplyNetworkConfig_NullData_ReturnsEmptyUpidWithoutStatus()
{
_mockClient.Setup(c => c.PutAsync($"nodes/{Node}/network", null))
.ReturnsAsync(@"{""data"": null}");
var task = _service.ApplyNetworkConfig(_session, Node);
Assert.Equal(string.Empty, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Null(task.Status);
}
[Fact]
public void ApplyNetworkConfig_ObjectShapedData_KeepsServerStatusAndOverridesNode()
{
_mockClient.Setup(c => c.PutAsync($"nodes/{Node}/network", null))
.ReturnsAsync($@"{{""data"": {{""upid"": ""{ApplyUpid}"", ""node"": ""other"", ""status"": ""stopped"", ""exitstatus"": ""OK""}}}}");
var task = _service.ApplyNetworkConfig(_session, Node);
Assert.Equal(ApplyUpid, task.Upid);
Assert.Equal(Node, task.Node);
Assert.Equal("stopped", task.Status);
Assert.Equal("OK", task.ExitStatus);
}
[Fact]
public void ApplyNetworkConfig_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.ApplyNetworkConfig(null!, Node));
}
// -----------------------------------------------------------------
// GetSdnZones / GetSdnVnets / GetSdnSubnets
// -----------------------------------------------------------------
[Fact]
public void GetSdnZones_ReturnsZoneArray()
{
_mockClient.Setup(c => c.GetAsync("cluster/sdn/zones"))
.ReturnsAsync(@"{""data"": [{""zone"": ""zone1"", ""type"": ""simple""}]}");
var zones = _service.GetSdnZones(_session);
Assert.Single(zones);
Assert.Equal("zone1", zones[0].Zone);
_mockClient.Verify(c => c.GetAsync("cluster/sdn/zones"), Times.Once);
}
[Fact]
public void GetSdnZones_NullData_ReturnsEmptyArray()
{
_mockClient.Setup(c => c.GetAsync("cluster/sdn/zones")).ReturnsAsync(@"{""data"": null}");
var zones = _service.GetSdnZones(_session);
Assert.Empty(zones);
}
[Fact]
public void GetSdnZones_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.GetSdnZones(null!));
}
[Fact]
public void GetSdnVnets_ReturnsVnetArray()
{
_mockClient.Setup(c => c.GetAsync("cluster/sdn/vnets"))
.ReturnsAsync(@"{""data"": [{""vnet"": ""vnet1"", ""zone"": ""zone1""}]}");
var vnets = _service.GetSdnVnets(_session);
Assert.Single(vnets);
Assert.Equal("vnet1", vnets[0].Vnet);
_mockClient.Verify(c => c.GetAsync("cluster/sdn/vnets"), Times.Once);
}
[Fact]
public void GetSdnVnets_NullData_ReturnsEmptyArray()
{
_mockClient.Setup(c => c.GetAsync("cluster/sdn/vnets")).ReturnsAsync(@"{""data"": null}");
var vnets = _service.GetSdnVnets(_session);
Assert.Empty(vnets);
}
[Fact]
public void GetSdnVnets_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.GetSdnVnets(null!));
}
[Fact]
public void GetSdnSubnets_RequestsVnetSubnetsPath()
{
_mockClient.Setup(c => c.GetAsync($"cluster/sdn/vnets/{Vnet}/subnets"))
.ReturnsAsync(@"{""data"": [{""subnet"": ""10.0.0.0/24""}]}");
var subnets = _service.GetSdnSubnets(_session, Vnet);
Assert.Single(subnets);
Assert.Equal("10.0.0.0/24", subnets[0].Subnet);
_mockClient.Verify(c => c.GetAsync($"cluster/sdn/vnets/{Vnet}/subnets"), Times.Once);
}
[Fact]
public void GetSdnSubnets_EscapesVnetInPath()
{
_mockClient.Setup(c => c.GetAsync(It.IsAny<string>())).ReturnsAsync(@"{""data"": []}");
_service.GetSdnSubnets(_session, "vnet 1");
_mockClient.Verify(c => c.GetAsync("cluster/sdn/vnets/vnet%201/subnets"), Times.Once);
}
[Fact]
public void GetSdnSubnets_NullData_ReturnsEmptyArray()
{
_mockClient.Setup(c => c.GetAsync($"cluster/sdn/vnets/{Vnet}/subnets")).ReturnsAsync(@"{""data"": null}");
var subnets = _service.GetSdnSubnets(_session, Vnet);
Assert.Empty(subnets);
}
[Fact]
public void GetSdnSubnets_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.GetSdnSubnets(null!, Vnet));
}
[Fact]
public void GetSdnSubnets_WhitespaceVnet_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("vnet", () => _service.GetSdnSubnets(_session, " "));
}
// -----------------------------------------------------------------
// CreateSdnZone
// -----------------------------------------------------------------
[Fact]
public void CreateSdnZone_RequiredFieldsOnly_SendsExactForm()
{
var captured = CapturePost(@"{""data"": {""zone"": ""zone1"", ""type"": ""simple""}}");
var config = new Dictionary<string, object> { ["zone"] = "zone1", ["type"] = "simple" };
var result = _service.CreateSdnZone(_session, config);
Assert.Equal(1, captured.Calls);
Assert.Equal("cluster/sdn/zones", captured.Path);
Assert.Equal(2, captured.Form!.Count);
Assert.Equal("zone1", result.Zone);
}
[Fact]
public void CreateSdnZone_NullSession_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["zone"] = "zone1" };
Assert.Throws<ArgumentNullException>("session", () => _service.CreateSdnZone(null!, config));
}
// -----------------------------------------------------------------
// CreateSdnVnet
// -----------------------------------------------------------------
[Fact]
public void CreateSdnVnet_RequiredFieldsOnly_SendsExactForm()
{
var captured = CapturePost(@"{""data"": {""vnet"": ""vnet1"", ""zone"": ""zone1""}}");
var config = new Dictionary<string, object> { ["vnet"] = "vnet1", ["zone"] = "zone1" };
var result = _service.CreateSdnVnet(_session, config);
Assert.Equal(1, captured.Calls);
Assert.Equal("cluster/sdn/vnets", captured.Path);
Assert.Equal(2, captured.Form!.Count);
Assert.Equal("vnet1", result.Vnet);
}
[Fact]
public void CreateSdnVnet_NullSession_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["vnet"] = "vnet1" };
Assert.Throws<ArgumentNullException>("session", () => _service.CreateSdnVnet(null!, config));
}
// -----------------------------------------------------------------
// CreateSdnSubnet
// -----------------------------------------------------------------
[Fact]
public void CreateSdnSubnet_RequiredFieldsOnly_SendsExactForm()
{
var captured = CapturePost(@"{""data"": null}");
var config = new Dictionary<string, object> { ["subnet"] = "10.0.0.0/24", ["type"] = "subnet" };
_service.CreateSdnSubnet(_session, Vnet, config);
Assert.Equal(1, captured.Calls);
Assert.Equal($"cluster/sdn/vnets/{Vnet}/subnets", captured.Path);
Assert.Equal("10.0.0.0/24", captured.Form!["subnet"]);
Assert.Equal("subnet", captured.Form["type"]);
Assert.Equal(2, captured.Form.Count);
}
[Fact]
public void CreateSdnSubnet_EscapesVnetInPath()
{
var captured = CapturePost(@"{""data"": null}");
var config = new Dictionary<string, object> { ["subnet"] = "10.0.0.0/24", ["type"] = "subnet" };
_service.CreateSdnSubnet(_session, "vnet 1", config);
Assert.Equal(1, captured.Calls);
Assert.Equal("cluster/sdn/vnets/vnet%201/subnets", captured.Path);
}
[Fact]
public void CreateSdnSubnet_NullSession_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["subnet"] = "10.0.0.0/24" };
Assert.Throws<ArgumentNullException>("session", () => _service.CreateSdnSubnet(null!, Vnet, config));
}
[Fact]
public void CreateSdnSubnet_WhitespaceVnet_ThrowsArgumentNullException()
{
var config = new Dictionary<string, object> { ["subnet"] = "10.0.0.0/24" };
Assert.Throws<ArgumentNullException>("vnet", () => _service.CreateSdnSubnet(_session, " ", config));
}
// -----------------------------------------------------------------
// RemoveSdnSubnet
// -----------------------------------------------------------------
[Fact]
public void RemoveSdnSubnet_CallsDeleteAsyncAgainstVnetSubnetPath()
{
_mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>())).ReturnsAsync(@"{""data"": null}");
_service.RemoveSdnSubnet(_session, Vnet, "10.0.0.0/24");
_mockClient.Verify(
c => c.DeleteAsync($"cluster/sdn/vnets/{Vnet}/subnets/10.0.0.0%2F24"), Times.Once);
_mockClient.VerifyNoOtherCalls();
}
[Fact]
public void RemoveSdnSubnet_EscapesVnetInPath()
{
_mockClient.Setup(c => c.DeleteAsync(It.IsAny<string>())).ReturnsAsync(@"{""data"": null}");
_service.RemoveSdnSubnet(_session, "vnet 1", "10.0.0.0/24");
_mockClient.Verify(
c => c.DeleteAsync("cluster/sdn/vnets/vnet%201/subnets/10.0.0.0%2F24"), Times.Once);
}
[Fact]
public void RemoveSdnSubnet_NullSession_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>("session", () => _service.RemoveSdnSubnet(null!, Vnet, "10.0.0.0/24"));
}
// -----------------------------------------------------------------
// RemoveSdnZone / RemoveSdnVnet
// ----------------------------------------------------------------- // -----------------------------------------------------------------
[Fact] [Fact]
public void RemoveSdnZone_EscapesPathTraversalInName() public void RemoveSdnZone_EscapesPathTraversalInName()
{ {
// Arrange
_mockClient.Setup(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx")) _mockClient.Setup(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"))
.ReturnsAsync(@"{""data"":null}"); .ReturnsAsync(@"{""data"":null}");
// Act
_service.RemoveSdnZone(_session, "../access/users/x"); _service.RemoveSdnZone(_session, "../access/users/x");
// Assert
_mockClient.Verify(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"), Times.Once); _mockClient.Verify(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"), Times.Once);
} }
// -----------------------------------------------------------------
// RemoveSdnVnet
// -----------------------------------------------------------------
[Fact] [Fact]
public void RemoveSdnVnet_EscapesPathTraversalInName() public void RemoveSdnVnet_EscapesPathTraversalInName()
{ {
// Arrange
_mockClient.Setup(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx")) _mockClient.Setup(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"))
.ReturnsAsync(@"{""data"":null}"); .ReturnsAsync(@"{""data"":null}");
// Act
_service.RemoveSdnVnet(_session, "../access/users/x"); _service.RemoveSdnVnet(_session, "../access/users/x");
// Assert
_mockClient.Verify(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"), Times.Once); _mockClient.Verify(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"), Times.Once);
} }
} }