diff --git a/README.md b/README.md index 45b73e9..afd2116 100644 --- a/README.md +++ b/README.md @@ -122,13 +122,43 @@ unifisharp clients # connected clients per site (name, type, ip, connectedAt With `UNIFI_LOCAL_HOST` alone the site URL is derived as `https:///proxy/network/api/s/default`. -> The **destructive** live tests (`UnifiLegacyLiveTests` — they create and delete a -> 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. +> The **destructive** live tests never read `UNIFI_*`; they run only against a throwaway +> container the tests start themselves (below). Read-only live checks +> (`UnifiLegacyReadOnlyLiveTests`) take their controller from the environment, in either mode. + +### Write tests run against their own container + +`UnifiLegacyLiveTests` (create and delete a port-forward, a firewall group and a VLAN) and +`UnifiLegacyNetworkRawLiveTests` (a raw DHCP-DNS write on a scratch network, then a +byte-for-byte restore) share `UnifiContainerFixture`: Testcontainers starts a UniFi OS +Server (`hieutq/unifi-os-server:5.0.8`, pinned by digest, with the settings of the Homelab +repo's `.containers/unifi/compose.yml`), runs first-run setup, logs in once, and removes the +container when the run ends. The URL comes from the container's mapped port, so these tests +cannot reach a real console whatever the environment says. + +```sh +dotnet test # starts the container when a runtime is reachable +UNIFISHARP_SKIP_CONTAINER_TESTS=1 dotnet test # skip them on purpose (1/true/yes) +UNIFISHARP_CONTAINER_STARTUP_MINUTES=40 dotnet test # slow link (default 20) +``` + +- **Needs** a Docker-compatible runtime that allows `cgroup: host`, the `SYS_ADMIN` set of + capabilities and a read-write `/sys/fs/cgroup` bind (OrbStack and Docker Desktop do). + With no runtime reachable the tests **skip** and say why; any other startup failure fails. +- **Local daemon only:** a `DOCKER_HOST` that is not a local socket or loopback address, or a + `TESTCONTAINERS_HOST_OVERRIDE` / `tc.host` that is not loopback, **fails** the tests rather + than start a `SYS_ADMIN` container elsewhere and send writes to it. +- **The image is pinned** (Testcontainers only pulls a missing image, so a floating tag would + keep whatever was pulled first). Bump the tag and digest deliberately. +- **First boot is slow:** every fresh container downloads Ubiquiti's installer (minutes). +- It gets its own name (`unifisharp-tests-…`), a random host port and only port 443, so it + runs beside a manually bootstrapped `unifi-os-server` container without touching it. +- Testcontainers finds the runtime through `DOCKER_HOST`, `~/.testcontainers.properties` or + the Docker context. A stale `tc.host` / `docker.host` in that properties file (say, from + an uninstalled Testcontainers Desktop) makes the tests skip; + `DOCKER_HOST=unix://$HOME/.orbstack/run/docker.sock` overrides it for OrbStack. +- **CI is untested.** A runner needs a privileged-enough Docker daemon and network access to + Ubiquiti's download servers; budget for the installer download on every run. ### Two surfaces, two sets of rules @@ -190,5 +220,5 @@ await fw.CreateAsync(spec, names); Read client + `discover` + `unifisharp` CLI building from the 10.4.57 spec. Live read tests skip without `UNIFI_*` — run them against the [`.containers/unifi`](https://github.com/Chrison-dev/Homelab/tree/main/.containers/unifi) -test controller (never the live network). **Next:** the legacy write adapter +test controller (never the live network). Legacy write tests start their own container. **Next:** the legacy write adapter (firewall rules / port profiles) once verified against the container. diff --git a/tests/UnifiSharp.Tests/UnifiContainerFixture.cs b/tests/UnifiSharp.Tests/UnifiContainerFixture.cs new file mode 100644 index 0000000..da7a745 --- /dev/null +++ b/tests/UnifiSharp.Tests/UnifiContainerFixture.cs @@ -0,0 +1,455 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using UnifiSharp.Legacy; +using Xunit; + +namespace UnifiSharp.Tests; + +#pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003) + +/// +/// A throwaway UniFi OS Server, started by Testcontainers for the legacy WRITE tests and +/// removed when the run ends. Same image and settings as +/// .containers/unifi/compose.yml in the Homelab repo, plus the first-run setup that +/// bootstrap.sh performs. +/// The client only ever points at this fixture's own container: the URL is built +/// from the container's mapped port, never read from the environment. That is the whole +/// safety argument for the write tests — no UNIFI_* variable can redirect them at a +/// real console. For the same reason the fixture refuses a remote Docker daemon or a +/// non-loopback host override: either would send the writes, and the privileged container, +/// somewhere other than this machine. +/// Skips (with the reason in ) when no container runtime is +/// reachable or UNIFISHARP_SKIP_CONTAINER_TESTS is 1/true/yes. Any other startup +/// failure — a timeout, a refused setup, a remote daemon — is a failure, not a skip. +/// +public sealed class UnifiContainerFixture : IAsyncLifetime +{ + // Pinned by digest: Testcontainers only pulls an image that is missing, so a floating + // tag would silently keep whatever was pulled first. 5.0.8 is the build the Homelab + // compose container runs and the README's findings were verified against. + public const string Image = "hieutq/unifi-os-server:5.0.8@sha256:71393f7afc736e196eeb64a0a98b32a03e094a1f23008bfcfc48d28d2660ef24"; + public const string Username = "labadmin"; + public const string Password = "LabAdmin!2026"; // 8-64 chars (UniFi OS policy); throwaway, container-only + public const string Label = "dev.chrison.unifisharp.tests"; + + private const string SkipVariable = "UNIFISHARP_SKIP_CONTAINER_TESTS"; + + // First boot downloads Ubiquiti's installer into the fresh container, which takes + // minutes on a good line. Generous on purpose; UNIFISHARP_CONTAINER_STARTUP_MINUTES + // overrides it for slow links (or shortens it when the image is warm). + private static readonly TimeSpan DefaultStartupTimeout = TimeSpan.FromMinutes(20); + + private IContainer? _container; + + /// Why the write tests are skipped; null when the container is up. + public string? SkipReason { get; private set; } + + /// The logged-in client, shared by every test in the collection. + public UnifiLegacyClient Client => + _client ?? throw new InvalidOperationException($"UniFi container is not available: {SkipReason}"); + private UnifiLegacyClient? _client; + + /// How long the container took to become usable — reported, not asserted. + public TimeSpan StartupTime { get; private set; } + + public async Task InitializeAsync() + { + if (SkipRequested(Environment.GetEnvironmentVariable(SkipVariable))) + { + SkipReason = $"{SkipVariable} is set — skipping the UniFi container write tests."; + return; + } + + var timeout = StartupTimeout(); + using var cts = new CancellationTokenSource(timeout); + var stopwatch = Stopwatch.StartNew(); + var progress = new StartupProgress(); + + try + { + _container = new ContainerBuilder() + .WithImage(Image) + // Our own name and label, never the compose container's `unifi-os-server`: a + // manually bootstrapped one may be running and is not ours to reuse or stop. + .WithName($"unifisharp-tests-{Guid.NewGuid().ToString("N")[..8]}") + .WithLabel(Label, "legacy-write") + // Only the GUI/API port, on a free random host port. 8080/3478/10003 are for + // real device adoption and would collide with the compose container. + .WithPortBinding(443, assignRandomHostPort: true) + .WithBindMount("/sys/fs/cgroup", "/sys/fs/cgroup", AccessMode.ReadWrite) + .WithCreateParameterModifier(p => + { + // compose.yml's `cgroup: host`, cap set and tmpfs mounts: UniFi OS Server runs + // systemd plus ~10 internal services. No /unifi volume — the data lives in the + // container layer and goes with it. + var host = p.HostConfig ?? throw new InvalidOperationException("Testcontainers built no HostConfig to modify."); + host.CgroupnsMode = "host"; + host.CapDrop = ["ALL"]; + host.CapAdd = + [ + "SYS_ADMIN", "NET_ADMIN", "NET_RAW", "NET_BIND_SERVICE", "DAC_OVERRIDE", + "DAC_READ_SEARCH", "FOWNER", "CHOWN", "SETUID", "SETGID", "KILL", + "SYS_CHROOT", "SYS_PTRACE", "SYS_RESOURCE", "AUDIT_WRITE", "MKNOD", + ]; + host.Tmpfs = new Dictionary + { + ["/run"] = "exec", + ["/run/lock"] = "", + ["/tmp"] = "exec", + ["/var/lib/journal"] = "", + ["/var/opt/unifi/tmp"] = "size=64m", + }; + }) + .Build(); + } + catch (DockerUnavailableException ex) + { + // Thrown from Build() when no endpoint is configured or reachable; nothing exists yet. + SkipReason = $"No container runtime reachable — skipping the UniFi container write tests. ({ex.Message.Split('\n')[0]})"; + return; + } + + // Before anything privileged starts: the daemon, and any host override, must be local. + RefuseIfNotLocal(TestcontainersSettings.OS.DockerEndpointAuthConfig?.Endpoint, TestcontainersSettings.DockerHostOverride); + + try + { + progress.Last = "starting the container"; + await _container.StartAsync(cts.Token); + + // Hostname is what the client will talk to, so it gets the same check: Testcontainers + // derives it from the endpoint and the override, and that derivation is not ours. + RefuseIfNotLocal(TestcontainersSettings.OS.DockerEndpointAuthConfig?.Endpoint, _container.Hostname); + + var root = new UriBuilder("https", _container.Hostname, _container.GetMappedPublicPort(443)).Uri; + await Bootstrap.RunAsync(root, progress, cts.Token); + + var options = new UnifiLegacyOptions + { + BaseUrl = new Uri(root, "/proxy/network/api/s/default"), + Username = Username, + Password = Password, + VerifyTls = false, // the container serves a self-signed cert + }; + // The session's handler backs off on a 429 from the login endpoint, which covers both + // the login below and the one the session repeats on a 401 mid-test. + var session = new UnifiLegacySession(options, new LoginBackoffHandler(new HttpClientHandler + { + CookieContainer = new CookieContainer(), + UseCookies = true, + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + })); + _client = new UnifiLegacyClient(session, ownsSession: true); + + // One login for the whole run, so the tests never add to the login rate limit. + progress.Last = "logging the shared session in"; + await session.LoginAsync(cts.Token); + } + catch (Exception ex) + { + // Don't count on xunit disposing a fixture whose initialisation failed: remove the + // container here, then surface the failure. + var name = _container.Name; + await DisposeAsync(); + _client = null; + _container = null; + if (ex is OperationCanceledException && cts.IsCancellationRequested) + { + throw new TimeoutException( + $"UniFi OS container {name} was not ready within {timeout}; last status: {progress.Last} " + + "(first boot downloads the installer; raise UNIFISHARP_CONTAINER_STARTUP_MINUTES on a slow link).", ex); + } + + throw; + } + + StartupTime = stopwatch.Elapsed; + } + + public async Task DisposeAsync() + { + _client?.Dispose(); + if (_container is not null) + { + await _container.DisposeAsync(); + } + } + + /// + /// Parses UNIFISHARP_SKIP_CONTAINER_TESTS: 1/true/yes skip, unset/0/false/no run, + /// anything else throws — a typo must not quietly decide whether the write tests run. + /// + internal static bool SkipRequested(string? value) => value?.Trim().ToLowerInvariant() switch + { + null or "" or "0" or "false" or "no" => false, + "1" or "true" or "yes" => true, + _ => throw new InvalidOperationException($"{SkipVariable} must be 1/true/yes or 0/false/no, got '{value}'."), + }; + + /// + /// Why the Docker endpoint or the host the tests will connect to is not this machine; + /// null when both are local. A socket or named pipe is a local daemon; a TCP/HTTP endpoint + /// and a host override must be loopback. An unknown endpoint is refused, not assumed local. + /// + internal static string? NonLocalDockerReason(Uri? endpoint, string? hostOverride) + { + if (endpoint is null) + { + return "the Docker endpoint could not be determined"; + } + + var localDaemon = endpoint.Scheme switch + { + "unix" or "npipe" => true, + "tcp" or "http" or "https" => IsLoopback(endpoint.Host), + _ => false, + }; + if (!localDaemon) + { + return $"the Docker endpoint {endpoint.OriginalString} is not a local socket or a loopback address"; + } + + if (!string.IsNullOrEmpty(hostOverride) && !IsLoopback(hostOverride)) + { + return $"the container host '{hostOverride}' (TESTCONTAINERS_HOST_OVERRIDE / tc.host, or derived from DOCKER_HOST) is not a loopback address"; + } + + return null; + } + + private static bool IsLoopback(string host) + { + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Uri.Host keeps IPv6 brackets ("[::1]"); IPAddress.TryParse does not want them. + return IPAddress.TryParse(host.Trim('[', ']'), out var address) && IPAddress.IsLoopback(address); + } + + private static void RefuseIfNotLocal(Uri? endpoint, string? host) + { + if (NonLocalDockerReason(endpoint, host) is { } reason) + { + // A failure, not a skip: the run asked for these tests and something is pointing + // them away from this machine. The container runs with SYS_ADMIN and a rw cgroup mount. + throw new InvalidOperationException( + $"Refusing to run the UniFi container write tests: {reason}. They run only against a local Docker daemon; " + + $"set DOCKER_HOST to a local socket, or {SkipVariable}=1 to skip them."); + } + } + + private static TimeSpan StartupTimeout() + { + var raw = Environment.GetEnvironmentVariable("UNIFISHARP_CONTAINER_STARTUP_MINUTES"); + if (string.IsNullOrEmpty(raw)) + { + return DefaultStartupTimeout; + } + + // A typo here should fail loudly, not quietly fall back to the default. + return int.TryParse(raw, out var minutes) && minutes > 0 + ? TimeSpan.FromMinutes(minutes) + : throw new InvalidOperationException($"UNIFISHARP_CONTAINER_STARTUP_MINUTES must be a positive whole number of minutes, got '{raw}'."); + } + + /// What startup last saw, for the timeout message. + private sealed class StartupProgress + { + public string Last { get; set; } = "not started"; + } + + /// + /// The controller's login rate limit answers 429 for a while; one schedule for every + /// login the fixture makes, readiness probe and shared session alike. + /// + internal static class LoginBackoff + { + /// Attempts before a 429 is handed back to the caller. + public const int MaxAttempts = 6; + + /// The wait after the -th consecutive 429 (1-based). + public static TimeSpan Delay(int attempt) + { + ArgumentOutOfRangeException.ThrowIfLessThan(attempt, 1); + var seconds = 15 * Math.Pow(2, Math.Min(attempt - 1, 4)); + return TimeSpan.FromSeconds(Math.Min(seconds, 120)); + } + } + + /// + /// Retries POST /api/auth/login on 429 with ; every other + /// request passes straight through. After the 429 + /// goes back to the session, which reports it as a failed login. + /// + private sealed class LoginBackoffHandler(HttpMessageHandler inner) : DelegatingHandler(inner) + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + if (request.Method != HttpMethod.Post || request.RequestUri?.AbsolutePath != "/api/auth/login") + { + return await base.SendAsync(request, ct); + } + + for (var attempt = 1; ; attempt++) + { + var resp = await base.SendAsync(request, ct); + if (resp.StatusCode != HttpStatusCode.TooManyRequests || attempt == LoginBackoff.MaxAttempts) + { + return resp; + } + + resp.Dispose(); + await Task.Delay(LoginBackoff.Delay(attempt), ct); + } + } + } + + /// + /// bootstrap.sh's steps 1-3, against one controller root: wait for UniFi OS, run first-run + /// setup, then wait until a legacy-API read answers 200. + /// + private static class Bootstrap + { + private static readonly TimeSpan Poll = TimeSpan.FromSeconds(5); + + public static async Task RunAsync(Uri root, StartupProgress progress, CancellationToken ct) + { + using var http = new HttpClient(new HttpClientHandler + { + // Throwaway container, self-signed cert. + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + // The readiness probe logs in once and keeps that session's cookie. + CookieContainer = new CookieContainer(), + UseCookies = true, + }) + { Timeout = TimeSpan.FromSeconds(15) }; + + // 1) UniFi OS answers /api/system with a deviceState once it is up. + await UntilAsync(progress, "waiting for /api/system", async () => + { + var body = await http.GetStringAsync(new Uri(root, "/api/system"), ct); + progress.Last = "/api/system answered without a deviceState"; + return body.Contains("\"deviceState\"", StringComparison.Ordinal); + }, ct); + + // 2) First-run setup. A fresh container is never set up, but the setup endpoint + // answers 400/500 to an empty body only once it accepts input; until then it + // is not ready. + await UntilAsync(progress, "waiting for /api/setup", async () => + { + using var probe = await http.PostAsJsonAsync(new Uri(root, "/api/setup"), new { }, ct); + progress.Last = $"/api/setup answered {(int)probe.StatusCode} to an empty body"; + return probe.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.InternalServerError; + }, ct); + try + { + using var setup = await http.PostAsJsonAsync(new Uri(root, "/api/setup"), + new { name = "UnifiSharp test controller", username = Username, password = Password }, ct); + if (!setup.IsSuccessStatusCode) + { + // An answer that says no is the reason, not something to wait out: without it + // the login below would only ever report "not ready". + var body = await setup.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"UniFi OS first-run setup was refused ({(int)setup.StatusCode}): {(body.Length > 300 ? body[..300] : body)}"); + } + + progress.Last = $"setup accepted ({(int)setup.StatusCode})"; + } + catch (HttpRequestException ex) + { + // The connection often resets while services restart after setup — expected. + // Whether setup took is decided by the login below, not by this response. + progress.Last = $"setup sent, response lost ({ex.Message})"; + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + // Or it hangs until HttpClient.Timeout, for the same reason. + progress.Last = "setup sent, response timed out"; + } + + // 3) The admin can log in and the legacy API answers. Its own session, not the + // shared one's: logged in once and reused, re-logged-in only on a 401, and + // backed off on a 429 so polling never feeds the login rate limit. + var loggedIn = false; + var rateLimited = 0; + await UntilAsync(progress, "waiting for the admin login and legacy API", async () => + { + if (!loggedIn) + { + using var login = await http.PostAsJsonAsync(new Uri(root, "/api/auth/login"), + new { username = Username, password = Password }, ct); + if (login.StatusCode == HttpStatusCode.TooManyRequests) + { + var delay = LoginBackoff.Delay(++rateLimited); + progress.Last = $"login answered 429 ({rateLimited} in a row); backing off {delay}"; + await Task.Delay(delay, ct); + return false; + } + + rateLimited = 0; + if (!login.IsSuccessStatusCode) + { + progress.Last = $"login answered {(int)login.StatusCode}"; + return false; + } + + loggedIn = true; + } + + using var read = await http.GetAsync(new Uri(root, "/proxy/network/api/s/default/rest/portforward"), ct); + progress.Last = $"legacy read answered {(int)read.StatusCode}"; + if (read.StatusCode == HttpStatusCode.Unauthorized) + { + loggedIn = false; // session gone (services restarting); log in again next poll + } + + return read.StatusCode == HttpStatusCode.OK; + }, ct); + } + + private static async Task UntilAsync(StartupProgress progress, string step, Func> ready, CancellationToken ct) + { + progress.Last = step; + while (true) + { + try + { + if (await ready()) + { + return; + } + } + catch (HttpRequestException ex) + { + // Not listening yet, or restarting a service — keep polling. + progress.Last = $"{step}: {ex.Message}"; + } + catch (TaskCanceledException) when (!ct.IsCancellationRequested) + { + // A single request timed out (HttpClient.Timeout), not the overall deadline. + progress.Last = $"{step}: request timed out"; + } + + await Task.Delay(Poll, ct); + } + } + } +} + +/// +/// Every legacy WRITE test class joins this collection: one container for the run, and the +/// tests in it run one at a time, so a test comparing "every other network" never sees +/// another test's scratch objects. +/// +[CollectionDefinition(Name)] +public sealed class UnifiContainerCollection : ICollectionFixture +{ + public const string Name = "UniFi OS container"; +} diff --git a/tests/UnifiSharp.Tests/UnifiContainerFixtureTests.cs b/tests/UnifiSharp.Tests/UnifiContainerFixtureTests.cs new file mode 100644 index 0000000..1ff7ac4 --- /dev/null +++ b/tests/UnifiSharp.Tests/UnifiContainerFixtureTests.cs @@ -0,0 +1,99 @@ +namespace UnifiSharp.Tests; + +// The fixture's decisions that need no container: the opt-out switch, the refusal to run +// the privileged container anywhere but this machine, and the login backoff schedule. +public class UnifiContainerFixtureTests +{ + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("True")] + [InlineData("yes")] + [InlineData("YES")] + [InlineData(" yes ")] + public void Skip_switch_accepts_the_usual_yes_spellings(string value) => + Assert.True(UnifiContainerFixture.SkipRequested(value)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("0")] + [InlineData("false")] + [InlineData("No")] + public void Skip_switch_unset_or_no_runs_the_tests(string? value) => + Assert.False(UnifiContainerFixture.SkipRequested(value)); + + [Theory] + [InlineData("y")] + [InlineData("on")] + [InlineData("2")] + public void Skip_switch_with_an_unrecognised_value_fails_loudly(string value) + { + // A typo must not silently run (or silently skip) the write tests. + var ex = Assert.Throws(() => UnifiContainerFixture.SkipRequested(value)); + Assert.Contains("UNIFISHARP_SKIP_CONTAINER_TESTS", ex.Message); + } + + [Theory] + [InlineData("unix:///var/run/docker.sock")] + [InlineData("unix:///Users/me/.orbstack/run/docker.sock")] + [InlineData("npipe://./pipe/docker_engine")] + [InlineData("tcp://localhost:2375")] + [InlineData("tcp://127.0.0.1:2375")] + [InlineData("tcp://[::1]:2375")] + [InlineData("http://LOCALHOST:2375")] + public void Local_docker_endpoints_are_accepted(string endpoint) => + Assert.Null(UnifiContainerFixture.NonLocalDockerReason(new Uri(endpoint), hostOverride: null)); + + [Theory] + [InlineData("tcp://10.0.0.14:2375")] + [InlineData("tcp://docker.homelab.internal:2376")] + [InlineData("https://192.168.1.20:2376")] + [InlineData("ssh://root@hpe-01")] + public void Remote_or_unrecognised_docker_endpoints_are_refused(string endpoint) + { + var reason = UnifiContainerFixture.NonLocalDockerReason(new Uri(endpoint), hostOverride: null); + + Assert.NotNull(reason); + Assert.Contains(endpoint.TrimEnd('/'), reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void An_unknown_docker_endpoint_is_refused_not_assumed_local() => + Assert.NotNull(UnifiContainerFixture.NonLocalDockerReason(endpoint: null, hostOverride: null)); + + [Theory] + [InlineData("localhost")] + [InlineData("127.0.0.1")] + [InlineData("::1")] + [InlineData("[::1]")] + public void A_loopback_host_override_is_accepted(string host) => + Assert.Null(UnifiContainerFixture.NonLocalDockerReason(new Uri("unix:///var/run/docker.sock"), host)); + + [Theory] + [InlineData("10.0.0.14")] + [InlineData("hpe-01.homelab.chrison.internal")] + [InlineData("host.docker.internal")] + public void A_remote_host_override_is_refused_even_with_a_local_socket(string host) + { + // The override is where the tests send their writes, so it alone can redirect them. + var reason = UnifiContainerFixture.NonLocalDockerReason(new Uri("unix:///var/run/docker.sock"), host); + + Assert.NotNull(reason); + Assert.Contains(host, reason); + } + + [Fact] + public void Login_backoff_starts_at_15s_doubles_and_is_capped_at_two_minutes() + { + var delays = Enumerable.Range(1, 6).Select(UnifiContainerFixture.LoginBackoff.Delay).ToArray(); + + Assert.Equal( + [ + TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(60), + TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(2), + ], + delays); + } +} diff --git a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs index 1893a1f..2046fa2 100644 --- a/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs +++ b/tests/UnifiSharp.Tests/UnifiLegacyLiveTests.cs @@ -4,30 +4,18 @@ namespace UnifiSharp.Tests; -// Live write round-trips against a UniFi controller — the .containers/unifi test -// container (run ./bootstrap.sh, then `set -a && . ./credentials.env`). Every test -// cleans up what it creates (add-only on the controller). +// Live tests against a UniFi controller, in two groups with different targets: // -// ⚠ These tests CREATE AND DELETE real objects — port-forwards, firewall groups and -// a VLAN. They are gated on SESSION auth (UNIFI_USERNAME + UNIFI_PASSWORD) on purpose, -// which is the only mode the test container offers. That gate is load-bearing: once -// UnifiLegacyOptions learned API-key auth, a plain `secrets.env` (UNIFI_API_KEY + -// UNIFI_LOCAL_HOST) started satisfying TryFromEnvironment — so keying these off -// TryFromEnvironment alone would silently point them at the live home gateway and -// create a VLAN on it. Read-only live checks use LegacyReadOnlyFixture instead. -// -// One shared session for the whole class (IClassFixture): the controller -// rate-limits logins (HTTP 429), so we log in once and reuse the cookie. +// • WRITE tests (UnifiLegacyLiveTests, UnifiLegacyNetworkRawLiveTests) CREATE AND DELETE +// real objects — port-forwards, firewall groups, VLANs, and a whole-network PUT. They +// run only against the throwaway container UnifiContainerFixture starts, and read no +// UNIFI_* variable at all: once UnifiLegacyOptions learned API-key auth, a plain +// `secrets.env` started satisfying TryFromEnvironment, so an env-gated write test could +// silently point at the live home gateway and create a VLAN on it. No env, no risk. +// • READ-ONLY tests (UnifiLegacyReadOnlyLiveTests) use LegacyReadOnlyFixture, which does +// take its controller from the environment — safe to point at a real gateway. #pragma warning disable CS0618 // UnifiLegacyClient is intentionally obsolete (ADR-0003) -public sealed class LegacyContainerFixture : IDisposable -{ - public UnifiLegacyClient? Client { get; } = - UnifiLegacyOptions.TryFromEnvironment() is { UsesApiKey: false } o ? new UnifiLegacyClient(o) : null; - - public void Dispose() => Client?.Dispose(); -} - /// /// Any configured controller, in either auth mode — for checks that only READ. /// Safe to point at a real gateway. @@ -106,18 +94,19 @@ public async Task Network_reads_raw_and_its_dhcp_dns_is_readable() } } -public class UnifiLegacyLiveTests : IClassFixture +[Collection(UnifiContainerCollection.Name)] +public class UnifiLegacyLiveTests { - private readonly UnifiLegacyClient? _client; - public UnifiLegacyLiveTests(LegacyContainerFixture fixture) => _client = fixture.Client; + private readonly UnifiContainerFixture _fixture; + public UnifiLegacyLiveTests(UnifiContainerFixture fixture) => _fixture = fixture; [SkippableFact] public async Task PortForward_create_list_delete_roundtrip() { - var client = _client; - Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + Skip.If(_fixture.SkipReason is not null, _fixture.SkipReason); + var client = _fixture.Client; - var created = await client!.CreatePortForwardAsync(new UnifiPortForward + var created = await client.CreatePortForwardAsync(new UnifiPortForward { Name = "unifisharp-test-pf", Enabled = true, @@ -150,10 +139,10 @@ public async Task PortForward_create_list_delete_roundtrip() [SkippableFact] public async Task FirewallGroup_create_delete_roundtrip() { - var client = _client; - Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + Skip.If(_fixture.SkipReason is not null, _fixture.SkipReason); + var client = _fixture.Client; - var created = await client!.CreateFirewallGroupAsync(new UnifiFirewallGroup + var created = await client.CreateFirewallGroupAsync(new UnifiFirewallGroup { Name = "unifisharp-test-grp", GroupType = "port-group", @@ -167,10 +156,10 @@ public async Task FirewallGroup_create_delete_roundtrip() [SkippableFact] public async Task Network_create_delete_roundtrip() { - var client = _client; - Skip.If(client is null, "No UNIFI_LEGACY_* env — skipping live legacy write test."); + Skip.If(_fixture.SkipReason is not null, _fixture.SkipReason); + var client = _fixture.Client; - var created = await client!.CreateNetworkAsync(new UnifiNetwork + var created = await client.CreateNetworkAsync(new UnifiNetwork { Name = "unifisharp-test-vlan", Purpose = "corporate", @@ -188,12 +177,16 @@ public async Task Network_create_delete_roundtrip() /// /// 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. +/// Runs against 's container, in the same collection as +/// the other write tests so none of them runs alongside it: it asserts every OTHER network is +/// untouched afterwards. /// +[Collection(UnifiContainerCollection.Name)] public class UnifiLegacyNetworkRawLiveTests { + private readonly UnifiContainerFixture _fixture; + public UnifiLegacyNetworkRawLiveTests(UnifiContainerFixture fixture) => _fixture = fixture; + private static readonly string[] Servers = ["10.20.0.53", "10.20.0.54"]; private static readonly HashSet DnsKeys = @@ -207,21 +200,8 @@ public class UnifiLegacyNetworkRawLiveTests [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); + Skip.If(_fixture.SkipReason is not null, _fixture.SkipReason); + var client = _fixture.Client; var before = await client.ListNetworksRawAsync(); var (vlan, octet) = PickUnusedVlanAndSubnet(before); @@ -290,16 +270,6 @@ public async Task Network_dhcp_dns_write_is_reversible_byte_for_byte() } } - 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). diff --git a/tests/UnifiSharp.Tests/UnifiSharp.Tests.csproj b/tests/UnifiSharp.Tests/UnifiSharp.Tests.csproj index a071f52..9403388 100644 --- a/tests/UnifiSharp.Tests/UnifiSharp.Tests.csproj +++ b/tests/UnifiSharp.Tests/UnifiSharp.Tests.csproj @@ -10,6 +10,7 @@ +