using GatewayKeepalive.Core; namespace GatewayKeepalive.Tests; internal sealed class CheckFailedException(string message, Exception? inner = null) : Exception(message, inner); internal static class Check { public static void True(bool condition, string message) { if (!condition) throw new CheckFailedException(message); } public static void False(bool condition, string message) => True(!condition, message); public static void Equal(T expected, T actual, string message) { if (!EqualityComparer.Default.Equals(expected, actual)) throw new CheckFailedException($"{message} (expected: {expected}, actual: {actual})"); } public static void Contains(string expected, string actual, string message) { if (!actual.Contains(expected, StringComparison.Ordinal)) throw new CheckFailedException($"{message} (missing: {expected}, actual: {actual})"); } public static void SequenceEqual(IEnumerable expected, IEnumerable actual, string message) { var left = expected.ToArray(); var right = actual.ToArray(); if (!left.SequenceEqual(right)) throw new CheckFailedException($"{message} (expected: [{string.Join(", ", left)}], actual: [{string.Join(", ", right)}])"); } public static TException Throws(Action action, string message) where TException : Exception { try { action(); } catch (TException exception) { return exception; } catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); } public static async Task ThrowsAsync(Func action, string message) where TException : Exception { try { await action(); } catch (TException exception) { return exception; } catch (Exception exception) { throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, got {exception.GetType().Name}: {exception.Message}", exception); } throw new CheckFailedException($"{message}: expected {typeof(TException).Name}, but no exception was thrown"); } public static async Task ThrowsAnyAsync(Func action, string message) { try { await action(); } catch (Exception exception) { return exception; } throw new CheckFailedException($"{message}: expected an exception, but no exception was thrown"); } } internal sealed class ManualTimeProvider : TimeProvider { private DateTimeOffset _utcNow = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); private long _timestamp; public override DateTimeOffset GetUtcNow() => _utcNow; public override long GetTimestamp() => _timestamp; public override long TimestampFrequency => TimeSpan.TicksPerSecond; public void Advance(TimeSpan amount) { if (amount < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(amount)); _utcNow += amount; _timestamp += amount.Ticks; } public void AdvanceSeconds(int seconds) => Advance(TimeSpan.FromSeconds(seconds)); } internal sealed class TempDirectory : IDisposable { public TempDirectory() { // The test host may not be allowed to create siblings in the system temp root. // Keep disposable state beside the test binary; it is still outside the product data paths. DirectoryPath = Path.Combine(AppContext.BaseDirectory, "test-temp", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(DirectoryPath); } public string DirectoryPath { get; } public void Dispose() { if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, recursive: true); } } internal sealed record BackendWrite(string Operation, string Target); internal sealed class FakeBackend(NetworkSnapshot initial) : INetworkBackend { public bool IsAdministrator { get; set; } = true; public NetworkSnapshot Current { get; set; } = initial; public List Writes { get; } = []; public int CaptureCount { get; private set; } public Func>? CaptureBehavior { get; set; } public Func? RouteWriteBehavior { get; set; } public Func? DnsWriteBehavior { get; set; } public Task> ListAdaptersAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult>([Current.Adapter]); } public async Task CaptureAsync(Guid adapterId, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); CaptureCount++; Check.Equal(Current.Adapter.Id, adapterId, "The service captured an unexpected adapter"); return CaptureBehavior is null ? Current : await CaptureBehavior(adapterId, cancellationToken); } public async Task WriteRoutesAsync(NetworkSnapshot desired, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Writes.Add(new("route", desired.Gateway ?? "")); if (RouteWriteBehavior is not null) await RouteWriteBehavior(desired); else ApplyRoutes(desired); } public async Task WriteDnsAsync(Guid adapterId, DnsConfiguration desired, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Check.Equal(Current.Adapter.Id, adapterId, "The service wrote DNS for an unexpected adapter"); var target = desired.Automatic ? "auto" : string.Join(",", desired.Servers); Writes.Add(new("dns", target)); if (DnsWriteBehavior is not null) await DnsWriteBehavior(desired); else ApplyDns(desired); } public void ApplyRoutes(NetworkSnapshot desired) => Current = Current with { Routes = desired.Routes }; public void ApplyDns(DnsConfiguration desired) => Current = Current with { Dns = desired, EffectiveDns = desired.Automatic ? [] : desired.Servers, }; } internal sealed class FakeProbe : IHealthProbe { public HealthReport Report { get; set; } = new([], ProxyHealthy: true, InternetHealthy: true, DateTimeOffset.UtcNow); public bool VerifyResult { get; set; } = true; public Exception? VerifyException { get; set; } public Func>? CheckBehavior { get; set; } public int CheckCount { get; private set; } public int VerifyCount { get; private set; } public NetworkSnapshot? VerifiedSnapshot { get; private set; } public CancellationToken VerifyToken { get; private set; } public async Task CheckAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); CheckCount++; return CheckBehavior is null ? Report with { CheckedAt = DateTimeOffset.UtcNow } : await CheckBehavior(settings, snapshot, cancellationToken); } public Task VerifyInternetAsync(AppSettings settings, NetworkSnapshot snapshot, CancellationToken cancellationToken) { VerifyCount++; VerifiedSnapshot = snapshot; VerifyToken = cancellationToken; if (VerifyException is not null) throw VerifyException; return Task.FromResult(VerifyResult); } } internal sealed class SwitchHarness : IDisposable { public SwitchHarness(NetworkSnapshot initial, bool administrator = true) { Backend = new FakeBackend(initial) { IsAdministrator = administrator }; Probe = new FakeProbe(); Store = new JsonStore(Temp.DirectoryPath); Service = new SwitchService(Backend, Probe, Store, Logs.Add); } public TempDirectory Temp { get; } = new(); public FakeBackend Backend { get; } public FakeProbe Probe { get; } public JsonStore Store { get; } public SwitchService Service { get; } public List Logs { get; } = []; public void Dispose() => Temp.Dispose(); } internal static class Fixtures { public static readonly Guid AdapterId = Guid.Parse("2C64EAB9-27A7-45CC-8C26-9DC77C54A58B"); public static AppSettings Settings() => new() { Configured = true, AdapterId = AdapterId, DirectGateway = "192.168.6.1", ProxyGateway = "192.168.6.2", ProxyDns = "192.168.6.2", DirectDnsAutomatic = true, UseSocks = true, SocksPort = 1070, TestHosts = ["probe.invalid"], RequiredHealthyTargets = 1, IntervalSeconds = 5, SuccessThreshold = 3, FailureThreshold = 3, RecoveryCooldownSeconds = 30, FailureCooldownSeconds = 5, ProbeTimeoutMs = 500, RoundTimeoutMs = 1000, Policy = ControlPolicy.Auto, }; public static NetworkSnapshot Baseline(string fingerprint = "network-a") { var adapter = new AdapterInfo( AdapterId, 7, "Offline test adapter", Up: true, Addresses: [new Ipv4Address("192.168.6.10", 24)], InterfaceMetric: 25, NetworkFingerprint: fingerprint); var route = new RouteEntry("192.168.6.1", 10, 0x1000, 7); return new NetworkSnapshot(adapter, [route], DnsConfiguration.Auto, ["192.168.6.1"], DateTimeOffset.UtcNow); } public static HealthReport Health(bool proxyHealthy, bool internetHealthy) => new([], proxyHealthy, internetHealthy, DateTimeOffset.UtcNow); }