Merge pull request #29 from GoodOlClint/fix/emit-native-types

feat: emit native types instead of Newtonsoft JObject/JArray in public APIs
This commit is contained in:
GoodOlClint
2026-03-25 11:46:57 -05:00
committed by GitHub
19 changed files with 259 additions and 56 deletions
+49
View File
@@ -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<string, object?>`, `List<T>`, `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<string, object?> GetNodeConfig(...) { ... }
// Model uses native type with converter for deserialization
[JsonProperty("members")]
[JsonConverter(typeof(NativeListConverter))]
public List<Dictionary<string, object?>>? Members { get; set; }
// Cmdlet OutputType uses native or module type
[OutputType(typeof(Dictionary<string, object>))]
// or
[OutputType(typeof(PSObject))]
```
+42 -6
View File
@@ -1,15 +1,15 @@
{
"_schema_version": "1.0",
"_description": "PSProxmoxVE stable findings ledger. IDs are permanent (F001, F002...). Resolved findings are never deleted — they are marked resolved with evidence. If a finding reappears, it is marked regressed and retains its original ID.",
"last_updated": "2026-03-24",
"last_scan_date": "2026-03-24",
"last_updated": "2026-03-25",
"last_scan_date": "2026-03-25",
"counters": {
"next_id": 85,
"next_id": 86,
"total_open": 10,
"total_resolved": 72,
"total_resolved": 73,
"total_regressed": 1,
"open": 10,
"resolved": 72,
"resolved": 73,
"regressed": 1,
"wont_fix": 1
},
@@ -1560,7 +1560,7 @@
"evidence": "Extracted to const string ApiTokenPrefix and CsrfHeaderName fields",
"verified_by": "self-reported"
},
"decisions_ref": "D013"
"decisions_ref": "D012"
},
{
"id": "F050",
@@ -2696,6 +2696,42 @@
"status": "new"
}
]
},
{
"id": "F085",
"title": "PveClusterJoinInfo exposes Newtonsoft JArray/JObject properties",
"category": "type_safety",
"severity": "high",
"status": "resolved",
"first_detected": "2026-03-25",
"decisions_ref": "D013",
"files": [
"src/PSProxmoxVE.Core/Models/Cluster/PveClusterJoinInfo.cs",
"src/PSProxmoxVE/Cmdlets/Cluster/GetPveClusterJoinInfoCmdlet.cs"
],
"description": "PveClusterJoinInfo.Nodelist is typed as JArray? and PveClusterJoinInfo.Totem is typed as JObject?. These Newtonsoft types are emitted directly to the PowerShell pipeline via Get-PveClusterJoinInfo, causing broken Get-Member, Format-Table, and Select-Object behavior. Nodelist should be List<Dictionary<string, object?>>? and Totem should be Dictionary<string, object?>?, with appropriate [JsonConverter] attributes for deserialization.",
"scan_history": [
{
"scan_date": "2026-03-25",
"local_id": "D013-scan-1",
"status": "new"
},
{
"scan_date": "2026-03-25",
"local_id": "D013-scan-2",
"status": "confirmed_open"
},
{
"scan_date": "2026-03-25",
"local_id": null,
"status": "fixed"
}
],
"resolution": {
"scan_date": "2026-03-25",
"evidence": "Replaced JArray? Nodelist with List<Dictionary<string, object?>>? + NativeListConverter, and JObject? Totem with Dictionary<string, object?>? + NativeDictionaryConverter. Removed Newtonsoft.Json.Linq dependency.",
"verified_by": "dotnet build + dotnet test (382 passed)"
}
}
]
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Utilities;
namespace PSProxmoxVE.Core.Models.Cluster;
@@ -17,10 +18,10 @@ public class PveClusterJoinInfo
/// <summary>
/// The list of nodes in the cluster with their connection details.
/// This is a complex nested structure returned as a JArray.
/// </summary>
[JsonProperty("nodelist")]
public JArray? Nodelist { get; set; }
[JsonConverter(typeof(NativeListConverter))]
public List<Dictionary<string, object?>>? Nodelist { get; set; }
/// <summary>
/// The preferred node to connect to when joining.
@@ -29,10 +30,11 @@ public class PveClusterJoinInfo
public string? PreferredNode { get; set; }
/// <summary>
/// The Corosync totem configuration as a raw JSON object.
/// The Corosync totem configuration.
/// </summary>
[JsonProperty("totem")]
public JObject? Totem { get; set; }
[JsonConverter(typeof(NativeDictionaryConverter))]
public Dictionary<string, object?>? Totem { get; set; }
/// <inheritdoc />
public override string ToString()
@@ -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; }
/// <summary>
/// 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.
/// </summary>
[JsonProperty("members")]
public JArray? Members { get; set; }
[JsonConverter(typeof(NativeListConverter))]
public List<Dictionary<string, object?>>? Members { get; set; }
/// <inheritdoc />
public override string ToString()
+4 -2
View File
@@ -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.
/// </summary>
[JsonProperty("properties")]
public JObject? Properties { get; set; }
[JsonConverter(typeof(NativeDictionaryConverter))]
public Dictionary<string, object?>? Properties { get; set; }
/// <inheritdoc />
public override string ToString()
@@ -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
/// <summary>
/// Returns the list of guests not covered by any backup job.
/// </summary>
public JArray GetNotBackedUp(PveSession session)
public List<Dictionary<string, object?>> 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
{
@@ -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
/// <summary>
/// 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.
/// </summary>
public JObject GetClusterConfig(PveSession session)
public Dictionary<string, object?> 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<string, object?>();
}
finally
{
@@ -258,7 +259,7 @@ namespace PSProxmoxVE.Core.Services
/// <summary>
/// Returns the Corosync totem configuration (GET /cluster/config/totem).
/// </summary>
public JObject GetTotem(PveSession session)
public Dictionary<string, object?> 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
/// <summary>
/// Returns the external quorum device (qdevice) status (GET /cluster/config/qdevice).
/// </summary>
public JObject GetQdevice(PveSession session)
public Dictionary<string, object?> 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
{
+3 -2
View File
@@ -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
/// <summary>
/// Returns the full HA manager status as a raw JSON object.
/// </summary>
public JObject GetManagerStatus(PveSession session)
public Dictionary<string, object?> 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
{
+5 -4
View File
@@ -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
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
public JObject GetNodeConfig(PveSession session, string node)
public Dictionary<string, object?> 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
/// </summary>
/// <param name="session">The authenticated PVE session.</param>
/// <param name="node">The cluster node name.</param>
public JObject GetNodeDns(PveSession session, string node)
public Dictionary<string, object?> 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
{
+4 -2
View File
@@ -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
/// <summary>
/// Gets the status/result of a guest agent exec command by PID.
/// </summary>
public JObject GetGuestExecStatus(PveSession session, string node, int vmid, int pid)
public Dictionary<string, object?> 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
{
@@ -0,0 +1,60 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace PSProxmoxVE.Core.Utilities
{
/// <summary>
/// Provides helper methods for converting Newtonsoft.Json types to native .NET types.
/// </summary>
public static class JsonHelper
{
/// <summary>
/// Converts a JToken to a native .NET type recursively.
/// JObject becomes Dictionary&lt;string, object?&gt;, JArray becomes List&lt;object?&gt;, JValue becomes its primitive value.
/// </summary>
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();
}
}
/// <summary>
/// Converts a JObject to a Dictionary&lt;string, object?&gt;.
/// </summary>
public static Dictionary<string, object?> ToDictionary(JObject? obj)
{
if (obj == null) return new Dictionary<string, object?>();
return obj.Properties().ToDictionary(
p => p.Name,
p => ToNative(p.Value));
}
/// <summary>
/// Converts a JArray to a List of Dictionaries.
/// Each element should be a JObject; non-object elements are skipped.
/// </summary>
public static List<Dictionary<string, object?>> ToListOfDictionaries(JArray? arr)
{
if (arr == null) return new List<Dictionary<string, object?>>();
return arr.OfType<JObject>().Select(ToDictionary).ToList();
}
}
}
@@ -0,0 +1,46 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace PSProxmoxVE.Core.Utilities
{
/// <summary>
/// JSON converter that deserializes a JSON array into a List of Dictionary&lt;string, object?&gt;.
/// Each array element is expected to be an object; non-object elements are skipped.
/// </summary>
public class NativeListConverter : JsonConverter<List<Dictionary<string, object?>>>
{
/// <inheritdoc />
public override List<Dictionary<string, object?>> ReadJson(JsonReader reader, Type objectType, List<Dictionary<string, object?>>? existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
return token is JArray arr ? JsonHelper.ToListOfDictionaries(arr) : new List<Dictionary<string, object?>>();
}
/// <inheritdoc />
public override void WriteJson(JsonWriter writer, List<Dictionary<string, object?>>? value, JsonSerializer serializer)
{
serializer.Serialize(writer, value);
}
}
/// <summary>
/// JSON converter that deserializes a JSON object into a Dictionary&lt;string, object?&gt;.
/// </summary>
public class NativeDictionaryConverter : JsonConverter<Dictionary<string, object?>>
{
/// <inheritdoc />
public override Dictionary<string, object?> ReadJson(JsonReader reader, Type objectType, Dictionary<string, object?>? existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
return token is JObject obj ? JsonHelper.ToDictionary(obj) : new Dictionary<string, object?>();
}
/// <inheritdoc />
public override void WriteJson(JsonWriter writer, Dictionary<string, object?>? value, JsonSerializer serializer)
{
serializer.Serialize(writer, value);
}
}
}
@@ -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<object>()));
}
pso.Properties.Add(new PSNoteProperty(kvp.Key, kvp.Value));
}
WriteObject(pso);
}
@@ -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
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveClusterConfig")]
[OutputType(typeof(JObject))]
[OutputType(typeof(Dictionary<string, object>))]
public sealed class GetPveClusterConfigCmdlet : PveCmdletBase
{
protected override void ProcessRecord()
@@ -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);
@@ -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);
@@ -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<string, object?> 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<int>() != 1);
} while (!result.TryGetValue("exited", out var exited) || !Equals(exited, 1L));
var output = new PSObject();
output.Properties.Add(new PSNoteProperty("ExitCode", result["exitcode"]?.ToObject<int>() ?? -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);
@@ -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<JArray>(result);
Assert.IsType<List<Dictionary<string, object?>>>(result);
Assert.Equal(2, result.Count);
Assert.Equal(100, result[0]["vmid"]!.Value<int>());
Assert.Equal("webserver", result[0]["name"]!.Value<string>());
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"": []}";
@@ -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<Dictionary<string, object?>>(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<Dictionary<string, object?>>(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());