From 87fa7a7a9cfdec17b7aef2e89ee5162a8556412e Mon Sep 17 00:00:00 2001 From: "goodolclint-claude[bot]" <323206664+goodolclint-claude[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:14:40 +0000 Subject: [PATCH] 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> --- .../Services/NetworkService.cs | 2 +- .../Cmdlets/Network/GetPveNetworkCmdlet.cs | 17 +- .../Cmdlets/Network/GetPveSdnSubnetCmdlet.cs | 14 +- .../Cmdlets/Network/GetPveSdnVnetCmdlet.cs | 13 +- .../Cmdlets/Network/GetPveSdnZoneCmdlet.cs | 13 +- .../Network/InvokePveNetworkApplyCmdlet.cs | 15 +- .../Cmdlets/Network/NewPveNetworkCmdlet.cs | 9 +- .../Cmdlets/Network/NewPveSdnSubnetCmdlet.cs | 12 +- .../Cmdlets/Network/NewPveSdnVnetCmdlet.cs | 10 +- .../Cmdlets/Network/NewPveSdnZoneCmdlet.cs | 9 +- .../Cmdlets/Network/RemovePveNetworkCmdlet.cs | 9 +- .../Network/RemovePveSdnSubnetCmdlet.cs | 9 +- .../Cmdlets/Network/SetPveNetworkCmdlet.cs | 9 +- .../Services/NetworkServiceTests.cs | 631 +++++++++++++++++- 14 files changed, 673 insertions(+), 99 deletions(-) diff --git a/src/PSProxmoxVE.Core/Services/NetworkService.cs b/src/PSProxmoxVE.Core/Services/NetworkService.cs index 5aded9e..321891b 100644 --- a/src/PSProxmoxVE.Core/Services/NetworkService.cs +++ b/src/PSProxmoxVE.Core/Services/NetworkService.cs @@ -597,7 +597,7 @@ namespace PSProxmoxVE.Core.Services { var data = JObject.Parse(response)["data"]; 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() ?? new PveTask(); task.Node = node; diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs index bfabaeb..4ad82a2 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveNetworkCmdlet.cs @@ -1,8 +1,6 @@ -using System; using System.Management.Automation; -using Newtonsoft.Json.Linq; -using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -37,20 +35,13 @@ namespace PSProxmoxVE.Cmdlets.Network protected override void ProcessRecord() { var session = GetSession(); - using var client = new PveHttpClient(session); WriteVerbose($"Getting network interfaces on node '{Node}'..."); - var resource = $"nodes/{Uri.EscapeDataString(Node)}/network"; - if (!string.IsNullOrEmpty(Type)) - resource += $"?type={Uri.EscapeDataString(Type)}"; + var service = new NetworkService(); + var networks = service.GetNetworks(session, Node, Type); - var json = client.GetAsync(resource).GetAwaiter().GetResult(); - var root = JObject.Parse(json); - var data = root["data"] as JArray ?? new JArray(); - - foreach (var item in data) + foreach (var network in networks) { - var network = item.ToObject()!; network.Node = Node; if (!string.IsNullOrEmpty(Iface) && !string.Equals(network.Iface, Iface, System.StringComparison.OrdinalIgnoreCase)) diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs index 8d066cb..6186720 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnSubnetCmdlet.cs @@ -1,7 +1,6 @@ using System.Management.Automation; -using Newtonsoft.Json.Linq; -using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -28,18 +27,13 @@ namespace PSProxmoxVE.Cmdlets.Network { var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose($"Getting SDN subnets for VNet '{Vnet}'..."); - var json = client.GetAsync($"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets") - .GetAwaiter().GetResult(); - var root = JObject.Parse(json); - var data = root["data"] as JArray ?? new JArray(); + var service = new NetworkService(); + var subnets = service.GetSdnSubnets(session, Vnet); - foreach (var item in data) + foreach (var subnet in subnets) { - var subnet = item.ToObject()!; - if (!string.IsNullOrEmpty(Subnet) && !string.Equals(subnet.Subnet, Subnet, System.StringComparison.OrdinalIgnoreCase)) continue; diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs index 305b442..ebc9995 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnVnetCmdlet.cs @@ -1,7 +1,6 @@ using System.Management.Automation; -using Newtonsoft.Json.Linq; -using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -28,17 +27,13 @@ namespace PSProxmoxVE.Cmdlets.Network { var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose("Getting SDN VNets..."); - var json = client.GetAsync("cluster/sdn/vnets").GetAwaiter().GetResult(); - var root = JObject.Parse(json); - var data = root["data"] as JArray ?? new JArray(); + var service = new NetworkService(); + var vnets = service.GetSdnVnets(session); - foreach (var item in data) + foreach (var vnet in vnets) { - var vnet = item.ToObject()!; - if (!string.IsNullOrEmpty(Zone) && !string.Equals(vnet.Zone, Zone, System.StringComparison.OrdinalIgnoreCase)) continue; diff --git a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs index f6aefa6..625080d 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/GetPveSdnZoneCmdlet.cs @@ -1,7 +1,6 @@ using System.Management.Automation; -using Newtonsoft.Json.Linq; -using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Network; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -23,17 +22,13 @@ namespace PSProxmoxVE.Cmdlets.Network { var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose("Getting SDN zones..."); - var resource = "cluster/sdn/zones"; - var json = client.GetAsync(resource).GetAwaiter().GetResult(); - var root = JObject.Parse(json); - var data = root["data"] as JArray ?? new JArray(); + var service = new NetworkService(); + var zones = service.GetSdnZones(session); - foreach (var item in data) + foreach (var zone in zones) { - var zone = item.ToObject()!; if (!string.IsNullOrEmpty(Zone) && !string.Equals(zone.Zone, Zone, System.StringComparison.OrdinalIgnoreCase)) continue; diff --git a/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs index 12c397d..60ce305 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/InvokePveNetworkApplyCmdlet.cs @@ -1,7 +1,4 @@ -using System; using System.Management.Automation; -using Newtonsoft.Json.Linq; -using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Vms; using PSProxmoxVE.Core.Services; @@ -33,19 +30,15 @@ namespace PSProxmoxVE.Cmdlets.Network return; var session = GetSession(); - using var client = new PveHttpClient(session); WriteVerbose($"Applying network configuration on node '{Node}'..."); - var json = client.PutAsync($"nodes/{Uri.EscapeDataString(Node)}/network").GetAwaiter().GetResult(); - var root = JObject.Parse(json); - var upid = root["data"]?.ToString() ?? string.Empty; + var service = new NetworkService(); + var task = service.ApplyNetworkConfig(session, Node); - var task = new PveTask { Upid = upid, Node = Node, Status = "running" }; - - if (Wait.IsPresent && !string.IsNullOrEmpty(upid)) + if (Wait.IsPresent && !string.IsNullOrEmpty(task.Upid)) { var taskService = new TaskService(); - task = taskService.WaitForTask(session, Node, upid); + task = taskService.WaitForTask(session, Node, task.Upid); } WriteObject(task); diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs index ccdfee9..2696158 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveNetworkCmdlet.cs @@ -1,7 +1,6 @@ -using System; using System.Collections.Generic; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -76,10 +75,9 @@ namespace PSProxmoxVE.Cmdlets.Network return; var session = GetSession(); - using var client = new PveHttpClient(session); WriteVerbose($"Creating network interface '{Iface}' on node '{Node}'..."); - var data = new Dictionary + var data = new Dictionary { ["iface"] = Iface, ["type"] = Type @@ -96,7 +94,8 @@ namespace PSProxmoxVE.Cmdlets.Network if (Autostart.IsPresent) data["autostart"] = "1"; 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); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs index 9bfb464..819b566 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnSubnetCmdlet.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -17,6 +17,7 @@ namespace PSProxmoxVE.Cmdlets.Network { /// The SDN VNet to add the subnet to. [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; /// The subnet CIDR notation (e.g. "10.0.0.0/24"). @@ -54,10 +55,8 @@ namespace PSProxmoxVE.Cmdlets.Network + $"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}'..."); - var data = new Dictionary + var data = new Dictionary { ["subnet"] = Subnet, ["type"] = "subnet" @@ -68,9 +67,8 @@ namespace PSProxmoxVE.Cmdlets.Network if (!string.IsNullOrEmpty(DnsZonePrefix)) data["dnszoneprefix"] = DnsZonePrefix!; if (!string.IsNullOrEmpty(DhcpRange)) data["dhcp-range"] = DhcpRange!; - client.PostAsync( - $"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets", data) - .GetAwaiter().GetResult(); + var service = new NetworkService(); + service.CreateSdnSubnet(session, Vnet, data); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs index e2c8fb7..df17a9d 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnVnetCmdlet.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -16,10 +16,12 @@ namespace PSProxmoxVE.Cmdlets.Network { /// The VNet identifier (alphanumeric, up to 8 characters). [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; /// The SDN zone this VNet belongs to. [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; /// VLAN tag for VLAN-type zones. @@ -41,10 +43,9 @@ namespace PSProxmoxVE.Cmdlets.Network var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose($"Creating SDN VNet '{Vnet}'..."); - var data = new Dictionary + var data = new Dictionary { ["vnet"] = Vnet, ["zone"] = Zone @@ -54,7 +55,8 @@ namespace PSProxmoxVE.Cmdlets.Network if (!string.IsNullOrEmpty(Alias)) data["alias"] = Alias!; if (VlanAware.IsPresent) data["vlanaware"] = "1"; - client.PostAsync("cluster/sdn/vnets", data).GetAwaiter().GetResult(); + var service = new NetworkService(); + service.CreateSdnVnet(session, data); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs index 48e26e0..bc1429c 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/NewPveSdnZoneCmdlet.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -16,6 +16,7 @@ namespace PSProxmoxVE.Cmdlets.Network { /// The zone identifier (alphanumeric, hyphens allowed). [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; /// The zone type. @@ -58,10 +59,9 @@ namespace PSProxmoxVE.Cmdlets.Network var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose($"Creating SDN zone '{Zone}'..."); - var data = new Dictionary + var data = new Dictionary { ["zone"] = Zone, ["type"] = Type @@ -75,7 +75,8 @@ namespace PSProxmoxVE.Cmdlets.Network if (!string.IsNullOrEmpty(DnsZone)) data["dnszone"] = DnsZone!; if (!string.IsNullOrEmpty(Ipam)) data["ipam"] = Ipam!; - client.PostAsync("cluster/sdn/zones", data).GetAwaiter().GetResult(); + var service = new NetworkService(); + service.CreateSdnZone(session, data); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs index ab17206..0b484d1 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveNetworkCmdlet.cs @@ -1,6 +1,5 @@ -using System; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -29,10 +28,10 @@ namespace PSProxmoxVE.Cmdlets.Network return; var session = GetSession(); - using var client = new PveHttpClient(session); - WriteVerbose($"Removing network interface '{Iface}' on node '{Node}'..."); - client.DeleteAsync($"nodes/{Uri.EscapeDataString(Node)}/network/{Uri.EscapeDataString(Iface)}").GetAwaiter().GetResult(); + WriteVerbose($"Removing network interface '{Iface}' from node '{Node}'..."); + var service = new NetworkService(); + service.RemoveNetwork(session, Node, Iface); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs index 91be456..226b28e 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/RemovePveSdnSubnetCmdlet.cs @@ -1,5 +1,5 @@ using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -16,6 +16,7 @@ namespace PSProxmoxVE.Cmdlets.Network { /// The SDN VNet containing the subnet. [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; /// The subnet CIDR to remove (e.g. "10.0.0.0/24"). @@ -29,12 +30,10 @@ namespace PSProxmoxVE.Cmdlets.Network var session = GetSession(); RequireVersion(session, "SDN", 6, 2, 8, 0); - using var client = new PveHttpClient(session); WriteVerbose($"Removing SDN subnet '{Subnet}' from VNet '{Vnet}'..."); - client.DeleteAsync( - $"cluster/sdn/vnets/{System.Uri.EscapeDataString(Vnet)}/subnets/{System.Uri.EscapeDataString(Subnet)}") - .GetAwaiter().GetResult(); + var service = new NetworkService(); + service.RemoveSdnSubnet(session, Vnet, Subnet); } } } diff --git a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs index e995834..097c90c 100644 --- a/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Network/SetPveNetworkCmdlet.cs @@ -1,7 +1,6 @@ -using System; using System.Collections.Generic; using System.Management.Automation; -using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Network { @@ -82,10 +81,9 @@ namespace PSProxmoxVE.Cmdlets.Network return; var session = GetSession(); - using var client = new PveHttpClient(session); WriteVerbose($"Updating network interface '{Iface}' on node '{Node}'..."); - var data = new Dictionary + var data = new Dictionary { ["type"] = Type }; @@ -109,7 +107,8 @@ namespace PSProxmoxVE.Cmdlets.Network if (Autostart.IsPresent) data["autostart"] = "1"; 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); } } } diff --git a/tests/PSProxmoxVE.Core.Tests/Services/NetworkServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/NetworkServiceTests.cs index ff8db6a..cb74e02 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/NetworkServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/NetworkServiceTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using Moq; using Xunit; using PSProxmoxVE.Core.Authentication; @@ -12,6 +14,10 @@ namespace PSProxmoxVE.Core.Tests.Services private readonly NetworkService _service; 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() { _mockClient = new Mock(); @@ -23,39 +29,642 @@ namespace PSProxmoxVE.Core.Tests.Services 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? 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(), It.IsAny>())) + .Callback?>((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(), It.IsAny>())) + .Callback?>((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())) + .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())) + .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())) + .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())) + .ReturnsAsync(@"{""data"": []}"); + + var networks = _service.GetNetworks(_session, Node); + + Assert.Empty(networks); + } + + [Fact] + public void GetNetworks_NullData_ReturnsEmptyArray() + { + _mockClient.Setup(c => c.GetAsync(It.IsAny())) + .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())) + .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())) + .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("session", () => _service.GetNetworks(null!, Node)); + } + + [Fact] + public void GetNetworks_WhitespaceNode_ThrowsArgumentNullException() + { + Assert.Throws("node", () => _service.GetNetworks(_session, " ")); + } + + // ----------------------------------------------------------------- + // CreateNetwork + // ----------------------------------------------------------------- + + [Fact] + public void CreateNetwork_RequiredFieldsOnly_SendsExactForm() + { + var captured = CapturePost(@"{""data"": {""iface"": ""vmbr1"", ""type"": ""bridge""}}"); + var config = new Dictionary { ["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 + { + ["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 + { + ["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 { ["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 { ["iface"] = "vmbr1" }; + Assert.Throws("session", () => _service.CreateNetwork(null!, Node, config)); + } + + [Fact] + public void CreateNetwork_NullConfig_ThrowsArgumentNullException() + { + Assert.Throws("config", () => _service.CreateNetwork(_session, Node, null!)); + } + + // ----------------------------------------------------------------- + // SetNetwork + // ----------------------------------------------------------------- + + [Fact] + public void SetNetwork_SendsConfigAsFormAgainstIfacePath() + { + var captured = CapturePut(@"{""data"": null}"); + var config = new Dictionary { ["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 { ["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 { ["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 { ["type"] = "bridge" }; + Assert.Throws("session", () => _service.SetNetwork(null!, Node, "vmbr0", config)); + } + + [Fact] + public void SetNetwork_WhitespaceIface_ThrowsArgumentNullException() + { + var config = new Dictionary { ["type"] = "bridge" }; + Assert.Throws("iface", () => _service.SetNetwork(_session, Node, " ", config)); + } + + // ----------------------------------------------------------------- + // RemoveNetwork + // ----------------------------------------------------------------- + + [Fact] + public void RemoveNetwork_CallsDeleteAsync() + { + _mockClient.Setup(c => c.DeleteAsync(It.IsAny())).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())).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("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("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("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("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())).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("session", () => _service.GetSdnSubnets(null!, Vnet)); + } + + [Fact] + public void GetSdnSubnets_WhitespaceVnet_ThrowsArgumentNullException() + { + Assert.Throws("vnet", () => _service.GetSdnSubnets(_session, " ")); + } + + // ----------------------------------------------------------------- + // CreateSdnZone + // ----------------------------------------------------------------- + + [Fact] + public void CreateSdnZone_RequiredFieldsOnly_SendsExactForm() + { + var captured = CapturePost(@"{""data"": {""zone"": ""zone1"", ""type"": ""simple""}}"); + var config = new Dictionary { ["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 { ["zone"] = "zone1" }; + Assert.Throws("session", () => _service.CreateSdnZone(null!, config)); + } + + // ----------------------------------------------------------------- + // CreateSdnVnet + // ----------------------------------------------------------------- + + [Fact] + public void CreateSdnVnet_RequiredFieldsOnly_SendsExactForm() + { + var captured = CapturePost(@"{""data"": {""vnet"": ""vnet1"", ""zone"": ""zone1""}}"); + var config = new Dictionary { ["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 { ["vnet"] = "vnet1" }; + Assert.Throws("session", () => _service.CreateSdnVnet(null!, config)); + } + + // ----------------------------------------------------------------- + // CreateSdnSubnet + // ----------------------------------------------------------------- + + [Fact] + public void CreateSdnSubnet_RequiredFieldsOnly_SendsExactForm() + { + var captured = CapturePost(@"{""data"": null}"); + var config = new Dictionary { ["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 { ["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 { ["subnet"] = "10.0.0.0/24" }; + Assert.Throws("session", () => _service.CreateSdnSubnet(null!, Vnet, config)); + } + + [Fact] + public void CreateSdnSubnet_WhitespaceVnet_ThrowsArgumentNullException() + { + var config = new Dictionary { ["subnet"] = "10.0.0.0/24" }; + Assert.Throws("vnet", () => _service.CreateSdnSubnet(_session, " ", config)); + } + + // ----------------------------------------------------------------- + // RemoveSdnSubnet + // ----------------------------------------------------------------- + + [Fact] + public void RemoveSdnSubnet_CallsDeleteAsyncAgainstVnetSubnetPath() + { + _mockClient.Setup(c => c.DeleteAsync(It.IsAny())).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())).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("session", () => _service.RemoveSdnSubnet(null!, Vnet, "10.0.0.0/24")); + } + + // ----------------------------------------------------------------- + // RemoveSdnZone / RemoveSdnVnet // ----------------------------------------------------------------- [Fact] public void RemoveSdnZone_EscapesPathTraversalInName() { - // Arrange _mockClient.Setup(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx")) .ReturnsAsync(@"{""data"":null}"); - // Act _service.RemoveSdnZone(_session, "../access/users/x"); - // Assert _mockClient.Verify(c => c.DeleteAsync("cluster/sdn/zones/..%2Faccess%2Fusers%2Fx"), Times.Once); } - // ----------------------------------------------------------------- - // RemoveSdnVnet - // ----------------------------------------------------------------- - [Fact] public void RemoveSdnVnet_EscapesPathTraversalInName() { - // Arrange _mockClient.Setup(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx")) .ReturnsAsync(@"{""data"":null}"); - // Act _service.RemoveSdnVnet(_session, "../access/users/x"); - // Assert _mockClient.Verify(c => c.DeleteAsync("cluster/sdn/vnets/..%2Faccess%2Fusers%2Fx"), Times.Once); } }