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(); +}