diff --git a/README.md b/README.md
index bc2b39a..45b73e9 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,9 @@ With `UNIFI_LOCAL_HOST` alone the site URL is derived as
> port-forward, a firewall group and a VLAN) are gated on *session* auth on purpose,
> so a shell holding only an API key can never point them at a production gateway.
> Read-only live checks (`UnifiLegacyReadOnlyLiveTests`) run in either mode.
+> `UnifiLegacyNetworkRawLiveTests` (a raw DHCP-DNS write on a scratch network, then a
+> byte-for-byte restore) is stricter still: it needs `UNIFI_LEGACY_BASE_URL` +
+> `UNIFI_USERNAME` + `UNIFI_PASSWORD` and **refuses** any host but localhost / 127.0.0.1 / ::1.
### Two surfaces, two sets of rules
@@ -143,6 +146,16 @@ nullable: on v2 a partial is not something you can send, so the type should not
Static-DNS **wildcards are supported** and match arbitrary labels
(`*.lab.example.com` answers `anything.lab.example.com`).
+**Networks are the exception on the legacy side.** `UnifiNetwork` models about a dozen of
+a live network's ~63 fields, so there is no typed network update. Change a network as raw
+JSON instead: read it, change named keys, put the whole object back.
+
+```csharp
+var net = await legacy.GetNetworkRawAsync(id); // throws unless exactly one row
+var dns = NetworkDhcpDns.Read(net); // null = auto
+await legacy.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(net, ["10.20.0.53", "10.20.0.54"]));
+```
+
## Firewall policies (`UnifiSharp.Firewall`)
Zone-based firewall policies (UniFi Network 9+) go through the **official** integration API,
diff --git a/src/UnifiSharp/Legacy/NetworkDhcpDns.cs b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs
new file mode 100644
index 0000000..8646d3f
--- /dev/null
+++ b/src/UnifiSharp/Legacy/NetworkDhcpDns.cs
@@ -0,0 +1,126 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace UnifiSharp.Legacy;
+
+///
+/// The DHCP DNS servers a rest/networkconf object hands out, kept as API knowledge
+/// in one place: dhcpd_dns_enabled plus four fixed slots dhcpd_dns_1..4.
+/// Works on the raw object from so a
+/// change touches these keys and nothing else.
+///
+public static class NetworkDhcpDns
+{
+ public const int MaxServers = 4;
+
+ private const string EnabledKey = "dhcpd_dns_enabled";
+
+ private static string SlotKey(int slot) => $"dhcpd_dns_{slot}";
+
+ ///
+ /// The servers handed out, slots 1..4 in order with blanks dropped; or null when
+ /// dhcpd_dns_enabled is false or absent ("auto": the gateway hands out itself, and
+ /// any leftover slot values are not live). Throws when a
+ /// key is present but not the shape this reads — unknown is reported, never guessed.
+ ///
+ public static IReadOnlyList? Read(JsonObject network)
+ {
+ ArgumentNullException.ThrowIfNull(network);
+
+ // Absent is treated as false: without the flag the controller does not apply the
+ // slots, so "auto" is the live behaviour. (Present-but-not-a-bool throws below.)
+ if (!network.TryGetPropertyValue(EnabledKey, out var enabled))
+ {
+ return null;
+ }
+
+ if (!ReadEnabled(enabled))
+ {
+ return null;
+ }
+
+ var servers = new List(MaxServers);
+ for (var slot = 1; slot <= MaxServers; slot++)
+ {
+ // An absent slot carries no server, the same as a blank one; With always writes all four.
+ if (!network.TryGetPropertyValue(SlotKey(slot), out var value))
+ {
+ continue;
+ }
+
+ var server = ReadSlot(slot, value);
+ if (!string.IsNullOrWhiteSpace(server))
+ {
+ servers.Add(server);
+ }
+ }
+
+ // Enabled with every slot blank comes back as an empty list, not null: it is a
+ // different live state from "auto", and the caller should see it as such.
+ return servers;
+ }
+
+ ///
+ /// A deep clone of with dhcpd_dns_enabled true, slots
+ /// 1..n set to in order and the remaining slots blanked to
+ /// "" (blanked, not removed: the PUT is a full replacement and an absent key is not
+ /// a cleared one). No other key is touched and the input is never mutated.
+ ///
+ /// 0 or more than servers, or a blank entry.
+ /// A DNS key is present but not the shape accepts.
+ public static JsonObject With(JsonObject network, IReadOnlyList servers)
+ {
+ ArgumentNullException.ThrowIfNull(network);
+ ArgumentNullException.ThrowIfNull(servers);
+
+ // Zero would mean "clear DNS", which no declaration should do by accident; reverting to
+ // auto is a separate, explicit operation this helper does not offer.
+ if (servers.Count is 0 or > MaxServers)
+ {
+ throw new ArgumentException(
+ $"A network hands out 1 to {MaxServers} DHCP DNS servers; got {servers.Count}.", nameof(servers));
+ }
+
+ if (servers.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("A DHCP DNS server cannot be blank.", nameof(servers));
+ }
+
+ // Same checks as Read, slots included even when disabled: a key shaped differently means
+ // the API drifted, and overwriting it would hide that rather than report it.
+ if (network.TryGetPropertyValue(EnabledKey, out var enabled))
+ {
+ ReadEnabled(enabled);
+ }
+
+ for (var slot = 1; slot <= MaxServers; slot++)
+ {
+ if (network.TryGetPropertyValue(SlotKey(slot), out var value))
+ {
+ ReadSlot(slot, value);
+ }
+ }
+
+ var copy = network.DeepClone().AsObject();
+ copy[EnabledKey] = true;
+ for (var slot = 1; slot <= MaxServers; slot++)
+ {
+ copy[SlotKey(slot)] = slot <= servers.Count ? servers[slot - 1] : "";
+ }
+
+ return copy;
+ }
+
+ private static bool ReadEnabled(JsonNode? enabled) =>
+ enabled?.GetValueKind() is JsonValueKind.True or JsonValueKind.False
+ ? enabled.GetValue()
+ : throw new UnifiLegacyException($"{EnabledKey} is {Describe(enabled)}, expected a boolean");
+
+ private static string ReadSlot(int slot, JsonNode? value) =>
+ value?.GetValueKind() is JsonValueKind.String
+ ? value.GetValue()
+ : throw new UnifiLegacyException($"{SlotKey(slot)} is {Describe(value)}, expected a string");
+
+ private static string Describe(JsonNode? node) =>
+ node is null ? "null" : $"{node.GetValueKind()} {node.ToJsonString()}";
+}
diff --git a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs
index 2cde8e5..3f7f977 100644
--- a/src/UnifiSharp/Legacy/UnifiLegacyClient.cs
+++ b/src/UnifiSharp/Legacy/UnifiLegacyClient.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace UnifiSharp.Legacy;
@@ -73,6 +74,64 @@ public Task CreateNetworkAsync(UnifiNetwork spec, CancellationToke
public Task DeleteNetworkAsync(string id, CancellationToken ct = default)
=> DeleteAsync("networkconf", id, ct);
+ // NOTE there is no UpdateNetworkAsync(UnifiNetwork), deliberately. UnifiNetwork models
+ // ~12 of a live network's ~63 fields and rest/networkconf/{id} is a full replacement,
+ // so a typed update would silently reset the rest. Updates go through the raw trio
+ // below: read the whole object, change named keys (e.g. NetworkDhcpDns.With), put it back.
+
+ /// Every network as the controller sent it — no field dropped, no number re-formatted.
+ public Task> ListNetworksRawAsync(CancellationToken ct = default)
+ => ListRawAsync("networkconf", ct);
+
+ ///
+ /// One network as the controller sent it. Throws unless the controller returns exactly
+ /// one row — zero or several is not "the" network, and a write built on it would be a guess.
+ ///
+ public async Task GetNetworkRawAsync(string id, CancellationToken ct = default)
+ {
+ ThrowUnlessPlainSegment(id);
+ using var resp = await _session.SendAsync(
+ HttpMethod.Get, $"rest/networkconf/{id}", content: null, ct).ConfigureAwait(false);
+ var rows = RawRows(await ReadEnvelopeAsync(resp, "networkconf", ct).ConfigureAwait(false), "networkconf");
+ return rows.Count == 1
+ ? rows[0]
+ : throw new UnifiLegacyException($"networkconf/{id}: expected exactly one network, got {rows.Count}");
+ }
+
+ ///
+ /// Replace a network wholesale with , sent verbatim. Pass an object
+ /// read by and changed in place of named keys only; the
+ /// caller's object is not modified. Returns the controller's echo of the network, or a copy
+ /// of when the controller answers ok with no row — re-read to confirm.
+ /// "Verbatim" is per value, not per byte, for strings: numbers keep their original text
+ /// and keys their order, but a string is re-escaped on write, so "caf\u00e9" read in
+ /// may go out as "café" (same value, different bytes).
+ ///
+ /// is not a plain path segment, or
+ /// 's _id is missing, not a string, or not .
+ public async Task ReplaceNetworkRawAsync(string id, JsonObject body, CancellationToken ct = default)
+ {
+ ThrowUnlessPlainSegment(id);
+ ArgumentNullException.ThrowIfNull(body);
+ // The PUT replaces the network at {id} with whatever body says, so a body read from
+ // another network would silently overwrite this one with it. Missing counts as mismatch.
+ var bodyId = body.TryGetPropertyValue("_id", out var idNode) && idNode?.GetValueKind() is JsonValueKind.String
+ ? idNode.GetValue()
+ : null;
+ if (bodyId != id)
+ {
+ throw new ArgumentException(
+ $"body._id is {(bodyId is null ? "missing or not a string" : $"'{bodyId}'")}, expected '{id}'.", nameof(body));
+ }
+
+ // ToJsonString writes parsed numbers from their original text, so 1.50 stays 1.50.
+ using var resp = await _session.SendAsync(
+ HttpMethod.Put, $"rest/networkconf/{id}", UnifiLegacySession.JsonBody(body.ToJsonString()), ct).ConfigureAwait(false);
+ var rows = RawRows(await ReadEnvelopeAsync(resp, "networkconf", ct).ConfigureAwait(false), "networkconf");
+ // Same echo-on-empty as UpdateAsync, but cloned so the caller's object stays theirs.
+ return rows.Count > 0 ? rows[0] : body.DeepClone().AsObject();
+ }
+
// ── Known clients / DHCP reservations (rest/user) ─────────────────────────
// NOTE there is no Delete here, deliberately. Removing a known client discards
// its name and history along with the reservation; retiring a reservation is
@@ -171,6 +230,31 @@ private async Task> ListAsync(string resource, CancellationT
return (await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false)).Data;
}
+ private async Task> ListRawAsync(string resource, CancellationToken ct)
+ {
+ using var resp = await _session.SendAsync(HttpMethod.Get, $"rest/{resource}", content: null, ct).ConfigureAwait(false);
+ return RawRows(await ReadEnvelopeAsync(resp, resource, ct).ConfigureAwait(false), resource);
+ }
+
+ // A null row is a shape we don't recognise, not an empty object — refuse it rather
+ // than hand back something a caller could write over a real network. Likewise "data":null:
+ // the deserializer leaves Data null despite its type, and that is not "no rows".
+ private static IReadOnlyList RawRows(UnifiLegacyEnvelope env, string resource) =>
+ (env.Data ?? throw new UnifiLegacyException($"{resource}: response data is null"))
+ .Select(r => r ?? throw new UnifiLegacyException($"{resource}: response row is null"))
+ .ToList();
+
+ // The id is interpolated into the path, so "a/../x" or "a?b" would address something else.
+ // Controller ids are hex ObjectIds; anything percent-encoding would change is refused, not escaped.
+ private static void ThrowUnlessPlainSegment(string id, [System.Runtime.CompilerServices.CallerArgumentExpression(nameof(id))] string? paramName = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id, paramName);
+ if (id is "." or ".." || Uri.EscapeDataString(id) != id)
+ {
+ throw new ArgumentException($"'{id}' is not a plain path segment.", paramName);
+ }
+ }
+
private async Task CreateAsync(string resource, T spec, CancellationToken ct)
{
var json = JsonSerializer.Serialize(spec, SerializerOptions);
diff --git a/src/UnifiSharp/Legacy/UnifiLegacySession.cs b/src/UnifiSharp/Legacy/UnifiLegacySession.cs
index bafe3f2..9ae918f 100644
--- a/src/UnifiSharp/Legacy/UnifiLegacySession.cs
+++ b/src/UnifiSharp/Legacy/UnifiLegacySession.cs
@@ -28,12 +28,21 @@ public sealed class UnifiLegacySession : IDisposable
private bool _loggedIn;
public UnifiLegacySession(UnifiLegacyOptions options)
+ : this(options, CreateHandler(options)) { }
+
+ // Test seam: lets unit tests assert the exact bytes on the wire without a controller.
+ internal UnifiLegacySession(UnifiLegacyOptions options, HttpMessageHandler handler)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
_options = options;
_loginUrl = new Uri(_options.ControllerRoot, "/api/auth/login");
+ _http = new HttpClient(handler);
+ }
+ private static HttpClientHandler CreateHandler(UnifiLegacyOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
var handler = new HttpClientHandler { CookieContainer = new CookieContainer(), UseCookies = true };
if (!options.VerifyTls)
{
@@ -41,7 +50,7 @@ public UnifiLegacySession(UnifiLegacyOptions options)
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
- _http = new HttpClient(handler);
+ return handler;
}
///
diff --git a/src/UnifiSharp/UnifiSharp.csproj b/src/UnifiSharp/UnifiSharp.csproj
index a38358a..397b81b 100644
--- a/src/UnifiSharp/UnifiSharp.csproj
+++ b/src/UnifiSharp/UnifiSharp.csproj
@@ -23,4 +23,9 @@
+
+
+
+
+
diff --git a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs
index 34eeb26..196067d 100644
--- a/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs
+++ b/tests/UnifiSharp.Tests/UnifiLegacyClientTests.cs
@@ -1,9 +1,13 @@
+using System.Net;
using System.Text.Json;
+using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using UnifiSharp.Legacy;
namespace UnifiSharp.Tests;
+#pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003)
+
// Pure unit tests for the legacy write adapter — no controller required.
public class UnifiLegacyClientTests
{
@@ -298,4 +302,299 @@ public void User_envelope_deserializes_reservation_fields()
Assert.Equal("10.10.135.221", u.FixedIp);
Assert.Equal("shell.devops.chrison.internal", u.LocalDnsRecord);
}
+
+ // ---- rest/networkconf as raw JSON — read-modify-write without the lossy model ----
+
+ // Synthetic, not captured: every JSON kind the controller can send, plus keys no model
+ // knows about. A number with a trailing zero and an exponent proves number TEXT survives,
+ // not just the value.
+ private const string RawNetwork =
+ """
+ { "_id": "net1", "name": "Consumer", "purpose": "corporate", "vlan": 1010,
+ "vlan_enabled": true, "dhcpd_dns_enabled": true,
+ "dhcpd_dns_1": "10.20.0.1", "dhcpd_dns_2": "", "dhcpd_dns_3": "", "dhcpd_dns_4": "",
+ "dhcpd_leasetime": 86400, "ratio": 1.50, "big": 1e3, "nothing": null,
+ "nat_outbound_ip_addresses": [], "ipv6_ra_preferred_lifetime": 14400,
+ "dhcp_relay": { "servers": ["10.0.0.1", null], "enabled": false },
+ "some_future_field": { "nested": [1, { "deep": true }] } }
+ """;
+
+ private static string Envelope(string dataArray, string rc = "ok", string? msg = null) =>
+ $$"""{ "meta": { "rc": "{{rc}}"{{(msg is null ? "" : $", \"msg\": \"{msg}\"")}} }, "data": {{dataArray}} }""";
+
+ [Fact]
+ public async Task GetNetworkRaw_gets_the_one_row_with_every_key_intact()
+ {
+ var handler = new FakeHandler(Envelope($"[{RawNetwork}]"));
+ using var client = handler.Client();
+
+ var net = await client.GetNetworkRawAsync("net1");
+
+ var req = Assert.Single(handler.Requests);
+ Assert.Equal(HttpMethod.Get, req.Method);
+ Assert.EndsWith("/api/s/default/rest/networkconf/net1", req.Uri.AbsolutePath);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), net));
+ Assert.Equal("1.50", net["ratio"]!.ToJsonString()); // number text, not a re-formatted double
+ Assert.True(net.ContainsKey("nothing")); // an explicit null is a key, not an absence
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("""[{ "_id": "a" }, { "_id": "b" }]""")]
+ [InlineData("[null]")]
+ public async Task GetNetworkRaw_throws_unless_exactly_one_row(string data)
+ {
+ // Zero rows or two is not "the" network — a read-modify-write on a guess is the bug.
+ using var client = new FakeHandler(Envelope(data)).Client();
+ await Assert.ThrowsAsync(() => client.GetNetworkRawAsync("net1"));
+ }
+
+ [Fact]
+ public async Task ListNetworksRaw_returns_every_row_raw()
+ {
+ var handler = new FakeHandler(Envelope($$"""[{{RawNetwork}}, { "_id": "net2", "x": 1 }]"""));
+ using var client = handler.Client();
+
+ var nets = await client.ListNetworksRawAsync();
+
+ Assert.EndsWith("/rest/networkconf", Assert.Single(handler.Requests).Uri.AbsolutePath);
+ Assert.Equal(2, nets.Count);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), nets[0]));
+ }
+
+ [Fact]
+ public async Task ReplaceNetworkRaw_puts_the_body_verbatim()
+ {
+ var handler = new FakeHandler(Envelope($"[{RawNetwork}]"));
+ using var client = handler.Client();
+ var read = await client.GetNetworkRawAsync("net1");
+
+ await client.ReplaceNetworkRawAsync("net1", read);
+
+ var put = handler.Requests[1];
+ Assert.Equal(HttpMethod.Put, put.Method);
+ Assert.EndsWith("/rest/networkconf/net1", put.Uri.AbsolutePath);
+ var sent = JsonNode.Parse(put.Body!)!.AsObject();
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), sent));
+ // Byte-for-byte on numbers: a double round-trip would print 1.5 and 1000.
+ Assert.Contains("\"ratio\":1.50", put.Body);
+ Assert.Contains("\"big\":1e3", put.Body);
+ Assert.Contains("\"vlan\":1010", put.Body);
+ Assert.Contains("\"nothing\":null", put.Body);
+ }
+
+ [Fact]
+ public async Task ReplaceNetworkRaw_does_not_mutate_the_callers_object()
+ {
+ using var client = new FakeHandler(Envelope("[]")).Client();
+ var body = JsonNode.Parse(RawNetwork)!.AsObject();
+
+ await client.ReplaceNetworkRawAsync("net1", body);
+
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), body));
+ }
+
+ [Theory]
+ [InlineData("GET")]
+ [InlineData("PUT")]
+ public async Task Raw_network_error_envelope_surfaces_the_api_msg(string verb)
+ {
+ // The legacy API answers validation failures with HTTP 400 and rc:error.
+ using var client = new FakeHandler(
+ Envelope("[]", rc: "error", msg: "api.err.InvalidPayload"), HttpStatusCode.BadRequest).Client();
+
+ var ex = await Assert.ThrowsAsync(() => verb == "GET"
+ ? client.GetNetworkRawAsync("net1")
+ : client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(RawNetwork)!.AsObject()));
+
+ Assert.Contains("api.err.InvalidPayload", ex.Message);
+ }
+
+ [Theory]
+ [InlineData("""{ "_id": "net2", "name": "Other" }""")]
+ [InlineData("""{ "name": "no id" }""")]
+ [InlineData("""{ "_id": null }""")]
+ [InlineData("""{ "_id": 1 }""")]
+ public async Task ReplaceNetworkRaw_refuses_a_body_that_is_not_the_named_network(string json)
+ {
+ // A full replacement of net1 with net2's body would silently turn one network into another.
+ var handler = new FakeHandler(Envelope("[]"));
+ using var client = handler.Client();
+
+ await Assert.ThrowsAsync(
+ () => client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(json)!.AsObject()));
+ Assert.Empty(handler.Requests);
+ }
+
+ [Theory]
+ [InlineData("a/../x")]
+ [InlineData("..")]
+ [InlineData(".")]
+ [InlineData("a?b=c")]
+ [InlineData("a#b")]
+ [InlineData("a%2Fb")]
+ [InlineData("a b")]
+ public async Task Raw_network_rejects_an_id_that_is_not_a_plain_path_segment(string id)
+ {
+ var handler = new FakeHandler(Envelope($"[{RawNetwork}]"));
+ using var client = handler.Client();
+ var body = JsonNode.Parse(RawNetwork)!.AsObject();
+ body["_id"] = id;
+
+ await Assert.ThrowsAsync(() => client.GetNetworkRawAsync(id));
+ await Assert.ThrowsAsync(() => client.ReplaceNetworkRawAsync(id, body));
+ Assert.Empty(handler.Requests);
+ }
+
+ [Theory]
+ [InlineData("""{ "meta": { "rc": "ok" }, "data": null }""")]
+ public async Task Raw_network_reports_a_null_data_array_as_an_api_shape_error(string response)
+ {
+ using var client = new FakeHandler(response).Client();
+
+ await Assert.ThrowsAsync(() => client.GetNetworkRawAsync("net1"));
+ await Assert.ThrowsAsync(() => client.ListNetworksRawAsync());
+ await Assert.ThrowsAsync(
+ () => client.ReplaceNetworkRawAsync("net1", JsonNode.Parse(RawNetwork)!.AsObject()));
+ }
+
+ [Fact]
+ public async Task ReplaceNetworkRaw_sends_With_output_byte_for_byte()
+ {
+ // DeepEquals compares values, so it cannot see number text, key order or a dropped
+ // null; only the literal wire body can. Expected is RawNetwork minified by hand, with
+ // the two DNS slots changed in place.
+ var handler = new FakeHandler(Envelope("[]"));
+ using var client = handler.Client();
+
+ await client.ReplaceNetworkRawAsync("net1", NetworkDhcpDns.With(Raw(), ["10.20.0.53", "10.20.0.54"]));
+
+ const string expected =
+ """{"_id":"net1","name":"Consumer","purpose":"corporate","vlan":1010,"vlan_enabled":true,"dhcpd_dns_enabled":true,"dhcpd_dns_1":"10.20.0.53","dhcpd_dns_2":"10.20.0.54","dhcpd_dns_3":"","dhcpd_dns_4":"","dhcpd_leasetime":86400,"ratio":1.50,"big":1e3,"nothing":null,"nat_outbound_ip_addresses":[],"ipv6_ra_preferred_lifetime":14400,"dhcp_relay":{"servers":["10.0.0.1",null],"enabled":false},"some_future_field":{"nested":[1,{"deep":true}]}}""";
+ Assert.Equal(expected, Assert.Single(handler.Requests).Body);
+ }
+
+ // ---- NetworkDhcpDns — the dhcpd_dns_N naming, kept out of every caller ----
+
+ private static JsonObject Raw() => JsonNode.Parse(RawNetwork)!.AsObject();
+
+ [Fact]
+ public void DhcpDns_With_changes_only_the_dns_keys_and_leaves_the_input_alone()
+ {
+ var input = Raw();
+
+ var output = NetworkDhcpDns.With(input, ["10.20.0.53", "10.20.0.54"]);
+
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(RawNetwork), input)); // input untouched
+ Assert.NotSame(input, output);
+
+ var changed = input.Select(kv => kv.Key)
+ .Union(output.Select(kv => kv.Key))
+ .Where(k => !JsonNode.DeepEquals(input[k], output[k]) || input.ContainsKey(k) != output.ContainsKey(k))
+ .ToHashSet();
+ // dhcpd_dns_enabled was already true and slots 3-4 already blank, so only 1 and 2 move here;
+ // the invariant is that nothing OUTSIDE the DNS key set moves.
+ Assert.Subset(
+ new HashSet { "dhcpd_dns_enabled", "dhcpd_dns_1", "dhcpd_dns_2", "dhcpd_dns_3", "dhcpd_dns_4" },
+ changed);
+ Assert.Equal("10.20.0.53", (string?)output["dhcpd_dns_1"]);
+ Assert.Equal("10.20.0.54", (string?)output["dhcpd_dns_2"]);
+ }
+
+ [Fact]
+ public void DhcpDns_With_enables_dns_and_writes_all_four_slots_from_auto()
+ {
+ var input = JsonNode.Parse("""{ "_id": "n", "dhcpd_dns_enabled": false, "other": 7 }""")!.AsObject();
+
+ var output = NetworkDhcpDns.With(input, ["10.20.0.53"]);
+
+ Assert.True((bool)output["dhcpd_dns_enabled"]!);
+ Assert.Equal("10.20.0.53", (string?)output["dhcpd_dns_1"]);
+ Assert.Equal("", (string?)output["dhcpd_dns_2"]);
+ Assert.Equal("", (string?)output["dhcpd_dns_3"]);
+ Assert.Equal("", (string?)output["dhcpd_dns_4"]);
+ Assert.Equal(7, (int)output["other"]!);
+ Assert.False((bool)input["dhcpd_dns_enabled"]!);
+ Assert.False(input.ContainsKey("dhcpd_dns_1"));
+ }
+
+ [Fact]
+ public void DhcpDns_With_blanks_a_dropped_slot_rather_than_removing_it()
+ {
+ // Removing the key would leave the controller's old value in place on a full PUT.
+ var input = NetworkDhcpDns.With(Raw(), ["1.1.1.1", "8.8.8.8", "9.9.9.9"]);
+
+ var output = NetworkDhcpDns.With(input, ["1.1.1.1", "8.8.8.8"]);
+
+ Assert.True(output.ContainsKey("dhcpd_dns_3"));
+ Assert.Equal("", (string?)output["dhcpd_dns_3"]);
+ Assert.Equal("9.9.9.9", (string?)input["dhcpd_dns_3"]);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(5)]
+ public void DhcpDns_With_rejects_an_empty_or_oversized_list(int count)
+ {
+ // Empty would mean "clear the DNS" — never something a declaration should do silently.
+ var servers = Enumerable.Range(1, count).Select(i => $"10.0.0.{i}").ToList();
+ Assert.Throws(() => NetworkDhcpDns.With(Raw(), servers));
+ }
+
+ [Theory]
+ [InlineData("""{ "dhcpd_dns_enabled": false, "dhcpd_dns_1": "1.1.1.1" }""")]
+ [InlineData("""{ "dhcpd_dns_1": "1.1.1.1" }""")]
+ public void DhcpDns_Read_is_null_when_dns_is_automatic(string json)
+ {
+ // Disabled means the gateway hands itself out; stale slot values are not the live list.
+ Assert.Null(NetworkDhcpDns.Read(JsonNode.Parse(json)!.AsObject()));
+ }
+
+ [Fact]
+ public void DhcpDns_Read_returns_the_slots_in_order_skipping_blanks()
+ {
+ var net = JsonNode.Parse(
+ """{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": "10.20.0.53", "dhcpd_dns_2": "", "dhcpd_dns_3": "10.20.0.54" }""")!
+ .AsObject();
+
+ Assert.Equal(["10.20.0.53", "10.20.0.54"], NetworkDhcpDns.Read(net));
+ }
+
+ [Theory]
+ [InlineData("""{ "dhcpd_dns_enabled": "yes" }""")]
+ [InlineData("""{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": 10 }""")]
+ public void DhcpDns_Read_throws_on_a_shape_it_does_not_recognise(string json)
+ {
+ // A field present but shaped differently is unknown, not "auto" and not empty.
+ Assert.Throws(() => NetworkDhcpDns.Read(JsonNode.Parse(json)!.AsObject()));
+ }
+
+ [Theory]
+ [InlineData("""{ "dhcpd_dns_enabled": "yes" }""")]
+ [InlineData("""{ "dhcpd_dns_enabled": null }""")]
+ [InlineData("""{ "dhcpd_dns_enabled": true, "dhcpd_dns_1": 10 }""")]
+ [InlineData("""{ "dhcpd_dns_enabled": false, "dhcpd_dns_2": { "ip": "1.1.1.1" } }""")]
+ public void DhcpDns_With_throws_on_a_shape_it_does_not_recognise(string json)
+ {
+ // Overwriting a malformed key would paper over the very drift Read refuses to guess at.
+ Assert.Throws(
+ () => NetworkDhcpDns.With(JsonNode.Parse(json)!.AsObject(), ["10.20.0.53"]));
+ }
+
+ // Records each request (body read eagerly — the client disposes it) and answers with a
+ // canned envelope. API-key options, so the session never makes a login request.
+ private sealed class FakeHandler(string responseBody, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler
+ {
+ public List<(HttpMethod Method, Uri Uri, string? Body)> Requests { get; } = [];
+
+ public UnifiLegacyClient Client() =>
+ new(new UnifiLegacySession(KeyOptions(), this), ownsSession: true);
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ var body = request.Content is null ? null : await request.Content.ReadAsStringAsync(ct);
+ Requests.Add((request.Method, request.RequestUri!, body));
+ return new HttpResponseMessage(status) { Content = new StringContent(responseBody) };
+ }
+ }
}
diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs
index ad048a8..1893a1f 100644
--- a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs
+++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs
@@ -1,3 +1,4 @@
+using System.Text.Json.Nodes;
using UnifiSharp.Legacy;
using Xunit;
@@ -88,6 +89,21 @@ public async Task Static_dns_reads_from_the_v2_site_api()
Assert.False(string.IsNullOrEmpty(r.RecordType));
});
}
+
+ [SkippableFact]
+ public async Task Network_reads_raw_and_its_dhcp_dns_is_readable()
+ {
+ Skip.If(_fixture.Client is null, "No UniFi env — skipping live read test.");
+
+ // Read only: proves the raw path and the dhcpd_dns_N naming against a real controller.
+ var networks = await _fixture.Client!.ListNetworksRawAsync();
+ Assert.NotEmpty(networks);
+ var first = networks[0];
+ var net = await _fixture.Client.GetNetworkRawAsync((string)first["_id"]!);
+
+ Assert.Equal((string?)first["_id"], (string?)net["_id"]);
+ _ = NetworkDhcpDns.Read(net); // null (auto) or a list — either way, no throw
+ }
}
public class UnifiLegacyLiveTests : IClassFixture
@@ -168,3 +184,160 @@ public async Task Network_create_delete_roundtrip()
await client.DeleteNetworkAsync(created.Id!);
}
}
+
+///
+/// Proves a field-scoped raw network write is reversible: setting DHCP DNS touches only the
+/// DNS keys, and putting back the saved object restores the network exactly.
+/// Stricter gate than : this PUTs over a whole network,
+/// so it runs only against a controller on this machine. Missing env skips; an env pointing
+/// anywhere else is refused (fails) before any login, never quietly skipped.
+///
+public class UnifiLegacyNetworkRawLiveTests
+{
+ private static readonly string[] Servers = ["10.20.0.53", "10.20.0.54"];
+
+ private static readonly HashSet DnsKeys =
+ ["dhcpd_dns_enabled", "dhcpd_dns_1", "dhcpd_dns_2", "dhcpd_dns_3", "dhcpd_dns_4"];
+
+ // Keys the controller rewrites on its own between reads, excluded from every comparison
+ // below. Empty until a run shows one — add each by name with the reason it moves, so a
+ // real difference can never hide here unannounced.
+ private static readonly HashSet VolatileKeys = [];
+
+ [SkippableFact]
+ public async Task Network_dhcp_dns_write_is_reversible_byte_for_byte()
+ {
+ var baseUrl = Environment.GetEnvironmentVariable("UNIFI_LEGACY_BASE_URL");
+ Skip.If(
+ string.IsNullOrEmpty(baseUrl)
+ || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNIFI_USERNAME"))
+ || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("UNIFI_PASSWORD")),
+ "UNIFI_LEGACY_BASE_URL + UNIFI_USERNAME + UNIFI_PASSWORD not all set — skipping local write test.");
+
+ // Check the URL the client will actually use, not just the variable: TryFromEnvironment
+ // is what resolves it, and it is the one that has to be local.
+ var options = UnifiLegacyOptions.TryFromEnvironment()
+ ?? throw new InvalidOperationException("UNIFI_* env is set but did not resolve to legacy options.");
+ RefuseUnlessLocal(new Uri(baseUrl!));
+ RefuseUnlessLocal(options.BaseUrl);
+
+ using var client = new UnifiLegacyClient(options);
+
+ var before = await client.ListNetworksRawAsync();
+ var (vlan, octet) = PickUnusedVlanAndSubnet(before);
+
+ var created = await client.CreateNetworkAsync(new UnifiNetwork
+ {
+ Name = $"scratch-dhcpdns-{Guid.NewGuid().ToString("N")[..8]}",
+ Purpose = "corporate",
+ VlanEnabled = true,
+ Vlan = vlan.ToString(),
+ IpSubnet = $"10.232.{octet}.1/24",
+ DhcpdEnabled = true,
+ DhcpdStart = $"10.232.{octet}.6",
+ DhcpdStop = $"10.232.{octet}.254",
+ });
+ Assert.False(string.IsNullOrEmpty(created.Id));
+ var id = created.Id!;
+
+ try
+ {
+ // Found by this test: the controller treats a PUT as a MERGE for omitted keys. A body
+ // without dhcpd_dns_enabled does not switch it off again, so "restore" cannot be done by
+ // replaying a body that lacks the key. Real LAN networks carry the key explicitly
+ // (dhcpd_dns_enabled=false), so the baseline is made explicit here, as on the live console.
+ var fresh = await client.GetNetworkRawAsync(id);
+ Assert.True(NetworkDhcpDns.Read(fresh) is null,
+ $"precondition: a new network should have DHCP DNS off (auto); dhcpd_dns_enabled is {fresh["dhcpd_dns_enabled"]?.ToJsonString() ?? "absent"}");
+ var explicitOff = Parse(fresh.ToJsonString());
+ explicitOff["dhcpd_dns_enabled"] = false;
+ for (var slot = 1; slot <= NetworkDhcpDns.MaxServers; slot++)
+ explicitOff[$"dhcpd_dns_{slot}"] = ""; // as on Consumer: all four keys present, blank when unused
+ await client.ReplaceNetworkRawAsync(id, explicitOff);
+
+ // Kept as text so nothing below can mutate the baseline.
+ var saved = (await client.GetNetworkRawAsync(id)).ToJsonString();
+ var savedObj = Parse(saved);
+ Assert.True(NetworkDhcpDns.Read(savedObj) is null && (bool?)savedObj["dhcpd_dns_enabled"] == false,
+ "precondition: the baseline carries dhcpd_dns_enabled=false explicitly");
+
+ await client.ReplaceNetworkRawAsync(id, NetworkDhcpDns.With(Parse(saved), Servers));
+ var changed = await client.GetNetworkRawAsync(id);
+
+ Assert.Equal(Servers, NetworkDhcpDns.Read(changed));
+ AssertSameKeys(savedObj, changed, ignore: DnsKeys, what: "after the DNS write, a non-DNS key");
+ Assert.Equal((string?)savedObj["_id"], (string?)changed["_id"]);
+ Assert.Equal((string?)savedObj["site_id"], (string?)changed["site_id"]);
+
+ await client.ReplaceNetworkRawAsync(id, Parse(saved));
+ var restored = await client.GetNetworkRawAsync(id);
+
+ AssertSameKeys(savedObj, restored, ignore: [], what: "after restoring the saved object, key");
+ }
+ finally
+ {
+ await client.DeleteNetworkAsync(id);
+ }
+
+ // Every other network, whole: the scratch network is gone and nothing else moved.
+ var after = await client.ListNetworksRawAsync();
+ var beforeById = ById(before);
+ var afterById = ById(after);
+ Assert.Equal(beforeById.Keys.Order(), afterById.Keys.Order());
+ foreach (var (otherId, was) in beforeById)
+ {
+ AssertSameKeys(was, afterById[otherId], ignore: [], what: $"network {otherId} ('{was["name"]}') changed: key");
+ }
+ }
+
+ private static void RefuseUnlessLocal(Uri url)
+ {
+ // Uri.Host lower-cases names and brackets IPv6, so ::1 arrives as "[::1]".
+ if (url.Host is not ("localhost" or "127.0.0.1" or "[::1]"))
+ {
+ throw new InvalidOperationException(
+ $"Refusing to write networks on {url.Host}: this test only runs against a controller on localhost / 127.0.0.1 / ::1.");
+ }
+ }
+
+ private static (int Vlan, int Octet) PickUnusedVlanAndSubnet(IReadOnlyList networks)
+ {
+ // node.ToString() gives the bare text for both shapes the vlan arrives in (number or string).
+ var vlans = networks.Select(n => n["vlan"]?.ToString()).ToHashSet();
+ var subnets = networks.Select(n => n["ip_subnet"]?.ToString() ?? "").ToList();
+ for (var attempt = 0; attempt < 20; attempt++)
+ {
+ var vlan = Random.Shared.Next(3900, 4000); // inside UniFi's valid 1–4009
+ var octet = Random.Shared.Next(0, 256);
+ if (!vlans.Contains(vlan.ToString()) && !subnets.Any(s => s.StartsWith($"10.232.{octet}.", StringComparison.Ordinal)))
+ {
+ return (vlan, octet);
+ }
+ }
+
+ throw new InvalidOperationException("No free VLAN 3900–3999 / 10.232.x.0/24 found for the scratch network.");
+ }
+
+ // Compared as each value's JSON text, not semantically: the claim is byte-for-byte, so 1
+ // vs 1.0 or a re-ordered nested object is a difference. The union of keys is walked, so a
+ // key the controller added or dropped is reported too.
+ private static void AssertSameKeys(JsonObject expected, JsonObject actual, IReadOnlyCollection ignore, string what)
+ {
+ foreach (var key in expected.Select(p => p.Key).Union(actual.Select(p => p.Key)))
+ {
+ if (ignore.Contains(key) || VolatileKeys.Contains(key))
+ {
+ continue;
+ }
+
+ var want = expected.TryGetPropertyValue(key, out var e) ? e?.ToJsonString() ?? "null" : "";
+ var got = actual.TryGetPropertyValue(key, out var a) ? a?.ToJsonString() ?? "null" : "";
+ Assert.True(want == got, $"{what} '{key}' differs: expected {want}, got {got}");
+ }
+ }
+
+ private static Dictionary ById(IReadOnlyList networks) =>
+ networks.ToDictionary(n => (string?)n["_id"] ?? throw new InvalidOperationException($"network without a string _id: {n.ToJsonString()}"));
+
+ private static JsonObject Parse(string json) => JsonNode.Parse(json)!.AsObject();
+}