diff --git a/DECISIONS.md b/DECISIONS.md index 7ee0671..8393e51 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -358,3 +358,52 @@ extracted to `const string` fields for maintainability. Auth header names (`PVEAPIToken=`, `CSRFPreventionToken`) were inline string literals used in multiple places. Extracting to `const string ApiTokenPrefix` and `CsrfHeaderName` fields improves maintainability and reduces typo risk. + +--- + +## D013 — Cmdlets must emit only native or module-defined types + +**Status**: Active +**Finding refs**: F085 +**Resolved in scan**: 2026-03-25 + +### Decision +All cmdlet output types and public model properties must be native .NET types (`string`, +`int`, `bool`, `Dictionary`, `List`, `PSObject`, `void`) or types +defined within the module itself (`Pve*` classes). Never expose third-party types like +Newtonsoft's `JObject`, `JArray`, or `JToken` in public APIs. + +### Rationale +PowerShell enumerates `JArray` unexpectedly and `JObject` properties are not discoverable +via `Get-Member` or tab completion. Users piping module output into `Format-Table`, +`Select-Object`, or `Where-Object` get confusing behavior when the underlying type is a +Newtonsoft container. Native dictionaries and lists work naturally in PowerShell pipelines. + +### Anti-pattern (do not reintroduce) +```csharp +// Service returning Newtonsoft type +public JObject GetNodeConfig(...) { ... } + +// Model exposing Newtonsoft type +[JsonProperty("members")] +public JArray? Members { get; set; } + +// Cmdlet OutputType referencing Newtonsoft type +[OutputType(typeof(JObject))] +``` + +### Correct pattern +```csharp +// Service returns native dictionary +public Dictionary GetNodeConfig(...) { ... } + +// Model uses native type with converter for deserialization +[JsonProperty("members")] +[JsonConverter(typeof(NativeListConverter))] +public List>? Members { get; set; } + +// Cmdlet OutputType uses native or module type +[OutputType(typeof(Dictionary))] +// or +[OutputType(typeof(PSObject))] +``` diff --git a/src/PSProxmoxVE.Core/Models/Cluster/PvePool.cs b/src/PSProxmoxVE.Core/Models/Cluster/PvePool.cs index 6c45837..1e866dc 100644 --- a/src/PSProxmoxVE.Core/Models/Cluster/PvePool.cs +++ b/src/PSProxmoxVE.Core/Models/Cluster/PvePool.cs @@ -1,5 +1,6 @@ +using System.Collections.Generic; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Models.Cluster; @@ -21,11 +22,12 @@ public class PvePool public string? Comment { get; set; } /// - /// The pool members (VMs, containers, storage). Returned as a raw array + /// The pool members (VMs, containers, storage). Returned as a list of dictionaries /// when querying a specific pool. /// [JsonProperty("members")] - public JArray? Members { get; set; } + [JsonConverter(typeof(NativeListConverter))] + public List>? Members { get; set; } /// public override string ToString() diff --git a/src/PSProxmoxVE.Core/Models/HA/PveHaRule.cs b/src/PSProxmoxVE.Core/Models/HA/PveHaRule.cs index 8205d75..30bf27f 100644 --- a/src/PSProxmoxVE.Core/Models/HA/PveHaRule.cs +++ b/src/PSProxmoxVE.Core/Models/HA/PveHaRule.cs @@ -1,5 +1,6 @@ +using System.Collections.Generic; using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Models.HA; @@ -43,7 +44,8 @@ public class PveHaRule /// Rule-specific properties that vary by rule type. /// [JsonProperty("properties")] - public JObject? Properties { get; set; } + [JsonConverter(typeof(NativeDictionaryConverter))] + public Dictionary? Properties { get; set; } /// public override string ToString() diff --git a/src/PSProxmoxVE.Core/Services/BackupService.cs b/src/PSProxmoxVE.Core/Services/BackupService.cs index d9e8381..c7033a5 100644 --- a/src/PSProxmoxVE.Core/Services/BackupService.cs +++ b/src/PSProxmoxVE.Core/Services/BackupService.cs @@ -5,6 +5,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Backup; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -173,7 +174,7 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the list of guests not covered by any backup job. /// - public JArray GetNotBackedUp(PveSession session) + public List> GetNotBackedUp(PveSession session) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -183,7 +184,7 @@ namespace PSProxmoxVE.Core.Services var response = client.GetAsync("cluster/backup-info/not-backed-up") .GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JArray ?? new JArray(); + return JsonHelper.ToListOfDictionaries(data as JArray); } finally { diff --git a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs index e780bde..a66f603 100644 --- a/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs +++ b/src/PSProxmoxVE.Core/Services/ClusterConfigService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Cluster; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -31,9 +32,9 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the cluster configuration directory (GET /cluster/config). - /// The response is a mixed structure returned as a raw JObject. + /// The response is a mixed structure returned as a Dictionary. /// - public JObject GetClusterConfig(PveSession session) + public Dictionary GetClusterConfig(PveSession session) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -42,7 +43,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync("cluster/config").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return data is JObject obj ? JsonHelper.ToDictionary(obj) : new Dictionary(); } finally { @@ -258,7 +259,7 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the Corosync totem configuration (GET /cluster/config/totem). /// - public JObject GetTotem(PveSession session) + public Dictionary GetTotem(PveSession session) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -267,7 +268,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync("cluster/config/totem").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return JsonHelper.ToDictionary(data as JObject); } finally { @@ -278,7 +279,7 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the external quorum device (qdevice) status (GET /cluster/config/qdevice). /// - public JObject GetQdevice(PveSession session) + public Dictionary GetQdevice(PveSession session) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -287,7 +288,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync("cluster/config/qdevice").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return JsonHelper.ToDictionary(data as JObject); } finally { diff --git a/src/PSProxmoxVE.Core/Services/HaService.cs b/src/PSProxmoxVE.Core/Services/HaService.cs index 5e9e4e0..e97bc41 100644 --- a/src/PSProxmoxVE.Core/Services/HaService.cs +++ b/src/PSProxmoxVE.Core/Services/HaService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.HA; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -357,7 +358,7 @@ namespace PSProxmoxVE.Core.Services /// /// Returns the full HA manager status as a raw JSON object. /// - public JObject GetManagerStatus(PveSession session) + public Dictionary GetManagerStatus(PveSession session) { if (session == null) throw new ArgumentNullException(nameof(session)); @@ -366,7 +367,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync("cluster/ha/status/manager_status").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return JsonHelper.ToDictionary(data as JObject); } finally { diff --git a/src/PSProxmoxVE.Core/Services/NodeService.cs b/src/PSProxmoxVE.Core/Services/NodeService.cs index 7d1e099..e9c4436 100644 --- a/src/PSProxmoxVE.Core/Services/NodeService.cs +++ b/src/PSProxmoxVE.Core/Services/NodeService.cs @@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Nodes; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -78,7 +79,7 @@ namespace PSProxmoxVE.Core.Services /// /// The authenticated PVE session. /// The cluster node name. - public JObject GetNodeConfig(PveSession session, string node) + public Dictionary GetNodeConfig(PveSession session, string node) { if (session == null) throw new ArgumentNullException(nameof(session)); if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); @@ -88,7 +89,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/config").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return JsonHelper.ToDictionary(data as JObject); } finally { @@ -124,7 +125,7 @@ namespace PSProxmoxVE.Core.Services /// /// The authenticated PVE session. /// The cluster node name. - public JObject GetNodeDns(PveSession session, string node) + public Dictionary GetNodeDns(PveSession session, string node) { if (session == null) throw new ArgumentNullException(nameof(session)); if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); @@ -134,7 +135,7 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/dns").GetAwaiter().GetResult(); var data = JObject.Parse(response)["data"]; - return data as JObject ?? new JObject(); + return JsonHelper.ToDictionary(data as JObject); } finally { diff --git a/src/PSProxmoxVE.Core/Services/VmService.cs b/src/PSProxmoxVE.Core/Services/VmService.cs index 2f4aae0..a615d03 100644 --- a/src/PSProxmoxVE.Core/Services/VmService.cs +++ b/src/PSProxmoxVE.Core/Services/VmService.cs @@ -6,6 +6,7 @@ using PSProxmoxVE.Core.Authentication; using PSProxmoxVE.Core.Client; using PSProxmoxVE.Core.Models.Nodes; using PSProxmoxVE.Core.Models.Vms; +using PSProxmoxVE.Core.Utilities; namespace PSProxmoxVE.Core.Services { @@ -624,7 +625,7 @@ namespace PSProxmoxVE.Core.Services /// /// Gets the status/result of a guest agent exec command by PID. /// - public JObject GetGuestExecStatus(PveSession session, string node, int vmid, int pid) + public Dictionary GetGuestExecStatus(PveSession session, string node, int vmid, int pid) { if (session == null) throw new ArgumentNullException(nameof(session)); if (string.IsNullOrWhiteSpace(node)) throw new ArgumentNullException(nameof(node)); @@ -634,7 +635,8 @@ namespace PSProxmoxVE.Core.Services { var response = client.GetAsync($"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/agent/exec-status?pid={pid}") .GetAwaiter().GetResult(); - return JObject.Parse(response)["data"] as JObject ?? new JObject(); + var data = JObject.Parse(response)["data"]; + return JsonHelper.ToDictionary(data as JObject); } finally { diff --git a/src/PSProxmoxVE.Core/Utilities/JsonHelper.cs b/src/PSProxmoxVE.Core/Utilities/JsonHelper.cs new file mode 100644 index 0000000..6910a1b --- /dev/null +++ b/src/PSProxmoxVE.Core/Utilities/JsonHelper.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace PSProxmoxVE.Core.Utilities +{ + /// + /// Provides helper methods for converting Newtonsoft.Json types to native .NET types. + /// + public static class JsonHelper + { + /// + /// Converts a JToken to a native .NET type recursively. + /// JObject becomes Dictionary<string, object>, JArray becomes List<object>, JValue becomes primitive. + /// + public static object? ToNative(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + return null; + + switch (token) + { + case JObject obj: + return obj.Properties().ToDictionary( + p => p.Name, + p => ToNative(p.Value)); + + case JArray arr: + return arr.Select(ToNative).ToList(); + + case JValue val: + return val.Value; + + default: + return token.ToString(); + } + } + + /// + /// Converts a JObject to a Dictionary<string, object?>. + /// + public static Dictionary ToDictionary(JObject? obj) + { + if (obj == null) return new Dictionary(); + return obj.Properties().ToDictionary( + p => p.Name, + p => ToNative(p.Value)); + } + + /// + /// Converts a JArray to a List of Dictionaries. + /// Each element should be a JObject; non-object elements are skipped. + /// + public static List> ToListOfDictionaries(JArray? arr) + { + if (arr == null) return new List>(); + return arr.OfType().Select(ToDictionary).ToList(); + } + } +} diff --git a/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs b/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs new file mode 100644 index 0000000..0c4b007 --- /dev/null +++ b/src/PSProxmoxVE.Core/Utilities/NativeJsonConverters.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace PSProxmoxVE.Core.Utilities +{ + /// + /// JSON converter that deserializes a JSON array into a List of Dictionary<string, object?>. + /// Each array element is expected to be an object; non-object elements are skipped. + /// + public class NativeListConverter : JsonConverter>> + { + /// + public override List> ReadJson(JsonReader reader, Type objectType, List>? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + return token is JArray arr ? JsonHelper.ToListOfDictionaries(arr) : new List>(); + } + + /// + public override void WriteJson(JsonWriter writer, List>? value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + } + + /// + /// JSON converter that deserializes a JSON object into a Dictionary<string, object?>. + /// + public class NativeDictionaryConverter : JsonConverter> + { + /// + public override Dictionary ReadJson(JsonReader reader, Type objectType, Dictionary? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + return token is JObject obj ? JsonHelper.ToDictionary(obj) : new Dictionary(); + } + + /// + public override void WriteJson(JsonWriter writer, Dictionary? value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs index 2cc33b3..a1e4835 100644 --- a/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Backup/GetPveBackupInfoCmdlet.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Management.Automation; using PSProxmoxVE.Core.Services; @@ -26,12 +27,9 @@ namespace PSProxmoxVE.Cmdlets.Backup foreach (var item in items) { var pso = new PSObject(); - if (item is Newtonsoft.Json.Linq.JObject jObj) + foreach (var kvp in item) { - foreach (var prop in jObj.Properties()) - { - pso.Properties.Add(new PSNoteProperty(prop.Name, prop.Value?.ToObject())); - } + pso.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value)); } WriteObject(pso); } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs index b5424a8..63daee8 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterConfigCmdlet.cs @@ -1,5 +1,5 @@ +using System.Collections.Generic; using System.Management.Automation; -using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Services; namespace PSProxmoxVE.Cmdlets.Cluster @@ -12,7 +12,7 @@ namespace PSProxmoxVE.Cmdlets.Cluster /// /// [Cmdlet(VerbsCommon.Get, "PveClusterConfig")] - [OutputType(typeof(JObject))] + [OutputType(typeof(Dictionary))] public sealed class GetPveClusterConfigCmdlet : PveCmdletBase { protected override void ProcessRecord() diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs index 39eaa53..856d05b 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeConfigCmdlet.cs @@ -28,9 +28,9 @@ namespace PSProxmoxVE.Cmdlets.Nodes var config = service.GetNodeConfig(session, Node); var psObj = new PSObject(); - foreach (var prop in config.Properties()) + foreach (var kvp in config) { - psObj.Properties.Add(new PSNoteProperty(prop.Name, prop.Value.ToString())); + psObj.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value)); } WriteObject(psObj); diff --git a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs index 5e831f8..f5857d8 100644 --- a/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Nodes/GetPveNodeDnsCmdlet.cs @@ -28,9 +28,9 @@ namespace PSProxmoxVE.Cmdlets.Nodes var dns = service.GetNodeDns(session, Node); var psObj = new PSObject(); - foreach (var prop in dns.Properties()) + foreach (var kvp in dns) { - psObj.Properties.Add(new PSNoteProperty(prop.Name, prop.Value.ToString())); + psObj.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value)); } WriteObject(psObj); diff --git a/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs index f521050..b61ba1e 100644 --- a/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Vms/InvokePveVmGuestExecCmdlet.cs @@ -53,19 +53,19 @@ namespace PSProxmoxVE.Cmdlets.Vms // Poll for completion with timeout var sw = Stopwatch.StartNew(); var deadline = TimeSpan.FromSeconds(Timeout); - Newtonsoft.Json.Linq.JObject result; + System.Collections.Generic.Dictionary result; do { System.Threading.Thread.Sleep(1000); if (sw.Elapsed >= deadline) throw new TimeoutException($"Guest command did not complete within {Timeout} seconds."); result = service.GetGuestExecStatus(session, Node, VmId, pid); - } while (result["exited"]?.ToObject() != 1); + } while (!result.TryGetValue("exited", out var exited) || !Equals(exited, 1L)); var output = new PSObject(); - output.Properties.Add(new PSNoteProperty("ExitCode", result["exitcode"]?.ToObject() ?? -1)); - output.Properties.Add(new PSNoteProperty("Stdout", DecodeBase64(result["out-data"]?.ToString()))); - output.Properties.Add(new PSNoteProperty("Stderr", DecodeBase64(result["err-data"]?.ToString()))); + output.Properties.Add(new PSNoteProperty("ExitCode", result.TryGetValue("exitcode", out var ec) && ec is long ecl ? (int)ecl : -1)); + output.Properties.Add(new PSNoteProperty("Stdout", DecodeBase64(result.TryGetValue("out-data", out var od) ? od?.ToString() : null))); + output.Properties.Add(new PSNoteProperty("Stderr", DecodeBase64(result.TryGetValue("err-data", out var ed) ? ed?.ToString() : null))); output.Properties.Add(new PSNoteProperty("Pid", pid)); WriteObject(output); diff --git a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs index ca6fb3a..727f96d 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/BackupServiceTests.cs @@ -360,7 +360,7 @@ namespace PSProxmoxVE.Core.Tests.Services // --------------------------------------------------------------- [Fact] - public void GetNotBackedUp_ReturnsJArray() + public void GetNotBackedUp_ReturnsListOfDictionaries() { // Arrange var json = @"{ @@ -379,14 +379,14 @@ namespace PSProxmoxVE.Core.Tests.Services var result = service.GetNotBackedUp(CreateSession()); // Assert - Assert.IsType(result); + Assert.IsType>>(result); Assert.Equal(2, result.Count); - Assert.Equal(100, result[0]["vmid"]!.Value()); - Assert.Equal("webserver", result[0]["name"]!.Value()); + Assert.Equal(100L, result[0]["vmid"]); + Assert.Equal("webserver", result[0]["name"]); } [Fact] - public void GetNotBackedUp_EmptyData_ReturnsEmptyJArray() + public void GetNotBackedUp_EmptyData_ReturnsEmptyList() { // Arrange var json = @"{""data"": []}"; diff --git a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs index fe60ec6..f0e14a3 100644 --- a/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Services/NodeServiceTests.cs @@ -71,7 +71,7 @@ namespace PSProxmoxVE.Core.Tests.Services } [Fact] - public void GetNodeConfig_ReturnsJObject() + public void GetNodeConfig_ReturnsDictionary() { // Arrange var json = @"{""data"": {""description"": ""Primary node"", ""wakeonlan"": ""AA:BB:CC:DD:EE:FF""}}"; @@ -84,6 +84,7 @@ namespace PSProxmoxVE.Core.Tests.Services // Assert Assert.NotNull(config); + Assert.IsType>(config); Assert.Equal("Primary node", config["description"]?.ToString()); Assert.Equal("AA:BB:CC:DD:EE:FF", config["wakeonlan"]?.ToString()); mockClient.Verify(c => c.GetAsync("nodes/pve1/config"), Times.Once); @@ -116,7 +117,7 @@ namespace PSProxmoxVE.Core.Tests.Services } [Fact] - public void GetNodeDns_ReturnsJObject() + public void GetNodeDns_ReturnsDictionary() { // Arrange var json = @"{""data"": {""dns1"": ""8.8.8.8"", ""dns2"": ""8.8.4.4"", ""search"": ""example.com""}}"; @@ -129,6 +130,7 @@ namespace PSProxmoxVE.Core.Tests.Services // Assert Assert.NotNull(dns); + Assert.IsType>(dns); Assert.Equal("8.8.8.8", dns["dns1"]?.ToString()); Assert.Equal("8.8.4.4", dns["dns2"]?.ToString()); Assert.Equal("example.com", dns["search"]?.ToString());