using GatewayKeepalive.Core; namespace GatewayKeepalive.Tests; internal static class GatewayControllerTests { public static async Task InitialUnconfiguredDoesNotProbeOrWriteAsync() { using var harness = new ControllerHarness(Fixtures.Baseline()); await harness.Controller.RefreshAsync(); Check.False(harness.Controller.Settings.Configured, "A fresh controller should remain unconfigured"); Check.Equal(NetworkMode.Initializing, harness.Controller.Status.Mode, "Unconfigured refresh should publish the initial state"); Check.Equal(0, harness.Backend.CaptureCount, "Unconfigured refresh must not capture the adapter"); Check.Equal(0, harness.Probe.CheckCount, "Unconfigured refresh must not probe"); Check.Equal(0, harness.Backend.Writes.Count, "Unconfigured refresh must not write"); Check.Equal(0, Directory.GetFiles(harness.Temp.DirectoryPath).Length, "Unconfigured refresh must not create state files"); } public static async Task ExplicitPausedSavePreservesRealDirectDnsAsync() { var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []); var baseline = Fixtures.Baseline() with { Dns = directDns, EffectiveDns = ["192.168.6.53"] }; using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings() with { Policy = new AppSettings().Policy, PreserveOriginalDirectDns = true, DirectDnsAutomatic = true, }; Check.Equal(ControlPolicy.Paused, settings.Policy, "The product default policy should be paused"); await harness.Controller.SaveSettingsAsync(settings); var session = harness.Store.Read("session.json"); Check.True(session is not null, "Save should persist the adoption session"); Check.False(string.IsNullOrWhiteSpace(session!.SettingsFingerprint), "Save should persist a settings fingerprint"); Check.False(session.DirectDns is null, "Save should preserve the real direct DNS configuration"); Check.False(session.DirectDns!.Automatic, "Static direct DNS must not be replaced by automatic DNS"); Check.SequenceEqual(["192.168.6.53"], session.DirectDns.Servers, "The adapter's static direct DNS should be retained"); Check.True(NetworkComparison.SameConfiguration(baseline, session.Baseline), "Baseline should be the real pre-adoption snapshot"); Check.True(NetworkComparison.SameConfiguration(baseline, session.Expected), "Expected should equal the current direct snapshot"); Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "An explicit first save should remain paused"); Check.Equal(0, harness.Backend.Writes.Count, "A paused save must not change routes or DNS"); Check.Equal(0, harness.Probe.CheckCount, "Save must not probe"); Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "The live configuration must remain untouched"); } public static async Task ExternalConfigurationChangeIsNotOverwrittenAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings(); await harness.Controller.SaveSettingsAsync(settings); var externalRoute = baseline.Routes[0] with { Gateway = "192.168.6.254", Metric = 7 }; harness.Backend.Current = baseline with { Routes = [externalRoute] }; await harness.Controller.RefreshAsync(); Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A changed route should require explicit adoption"); Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "External configuration should pause automatic control"); Check.Equal(0, harness.Probe.CheckCount, "External configuration must be detected before probing"); Check.Equal(0, harness.Backend.Writes.Count, "External configuration must never be overwritten automatically"); Check.Equal("192.168.6.254", harness.Backend.Current.Gateway, "The external route should remain in place"); Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "Refresh must not rewrite the saved policy"); } public static async Task LegacySessionWithoutFingerprintIsNotWritableAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline, Fixtures.Settings()); harness.Store.Write("session.json", new SessionState(baseline, baseline, DnsConfiguration.Auto)); await harness.Controller.RefreshAsync(); Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A session with the default empty fingerprint must not claim the new settings"); Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Legacy session refusal should pause automatic control"); Check.Equal(0, harness.Probe.CheckCount, "Fingerprint mismatch must be rejected before probing"); Check.Equal(0, harness.Backend.Writes.Count, "Fingerprint mismatch must not write"); } public static async Task SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync() { var baseline = Fixtures.Baseline(); var oldSettings = Fixtures.Settings() with { Policy = ControlPolicy.Paused, ProxyDns = "192.168.6.2" }; using var harness = new ControllerHarness(baseline, oldSettings); var settingsPath = harness.FilePath("settings.json"); File.Delete(settingsPath); Directory.CreateDirectory(settingsPath); var uncommitted = oldSettings with { ProxyDns = "192.168.6.3" }; await Check.ThrowsAsync( () => harness.Controller.SaveSettingsAsync(uncommitted), "A settings write failure should fail the save"); Check.True(harness.Store.Read("session.json") is not null, "The test should fail after session.json was written"); Check.Equal("192.168.6.2", harness.Controller.Settings.ProxyDns, "Failed save must not publish uncommitted settings in memory"); await harness.Controller.RefreshAsync(); Check.Equal(NetworkMode.ExternalConfiguration, harness.Controller.Status.Mode, "A partial session/settings write must not become writable"); Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "A partial write should pause automatic control"); Check.Equal(0, harness.Probe.CheckCount, "A partial write must not reach health probing"); Check.Equal(0, harness.Backend.Writes.Count, "A partial write must not alter routes or DNS"); Check.Equal("192.168.6.2", harness.Store.Read("settings.json")?.ProxyDns ?? harness.Controller.Settings.ProxyDns, "No new settings should be committed"); } public static async Task UnfinishedJournalBlocksAutomaticControlAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); var intended = baseline with { Routes = [ baseline.Routes[0] with { Metric = 101 }, new RouteEntry("192.168.6.2", 1, baseline.Routes[0].InterfaceLuid, baseline.Routes[0].InterfaceIndex), ], Dns = new DnsConfiguration(false, ["192.168.6.2"], []), }; var journal = new SwitchJournal(baseline, intended, "ApplyingDns", DateTimeOffset.UtcNow); harness.Store.Write(SwitchService.JournalFile, journal); await harness.Controller.RefreshAsync(); Check.Equal(NetworkMode.RecoveryRequired, harness.Controller.Status.Mode, "An unfinished journal must require recovery"); Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Automatic control must pause for an unfinished journal"); Check.Equal(0, harness.Probe.CheckCount, "A journal must block checks before probing"); Check.Equal(0, harness.Backend.Writes.Count, "A journal must block all automatic writes"); Check.True(harness.Store.Read(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal"); Check.Equal(ControlPolicy.Auto, harness.Store.Read("settings.json")?.Policy, "The saved Auto policy should remain available for after recovery"); } public static async Task FixedFailureKeepsFixedPolicyAfterRollbackAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings() with { SuccessThreshold = 1 }; await harness.Controller.SaveSettingsAsync(settings); harness.Probe.VerifyResult = false; await Check.ThrowsAsync( () => harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy), "Failed fixed-mode egress verification should fail the policy change"); Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "A successful rollback must retain the fixed policy"); Check.Equal(ControlPolicy.ForceProxy, harness.Store.Read("settings.json")?.Policy, "The persisted policy must remain fixed"); Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "Rollback should restore the direct configuration"); Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful rollback should clear the journal"); Check.Equal(4, harness.Backend.Writes.Count, "Fixed switch should write and then fully roll back"); harness.Probe.Report = Fixtures.Health(proxyHealthy: true, internetHealthy: true); await harness.Controller.RefreshAsync(); Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Status.Policy, "Health evaluation must not replace the fixed policy with Auto"); Check.Equal(ControlPolicy.ForceProxy, harness.Controller.Settings.Policy, "Refresh must keep the fixed policy in memory"); Check.Equal(NetworkMode.Direct, harness.Controller.Status.Mode, "The rollback should leave the controller in direct mode"); Check.Equal(4, harness.Backend.Writes.Count, "A fixed policy failure must not trigger an automatic retry"); Check.Equal(1, harness.Probe.CheckCount, "Refresh should observe health without switching"); } public static async Task ConcurrentRefreshIsCoalescedAsync() { using var harness = new ControllerHarness(Fixtures.Baseline()); await harness.Controller.SaveSettingsAsync(Fixtures.Settings()); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); harness.Probe.CheckBehavior = async (_, _, cancellationToken) => { entered.TrySetResult(); await release.Task.WaitAsync(cancellationToken); return Fixtures.Health(proxyHealthy: true, internetHealthy: true); }; var first = harness.Controller.RefreshAsync(); try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var second = harness.Controller.RefreshAsync(); await second.WaitAsync(TimeSpan.FromSeconds(1)); Check.Equal(1, harness.Probe.CheckCount, "A concurrent refresh should be merged into the running check"); } finally { release.TrySetResult(); } await first.WaitAsync(TimeSpan.FromSeconds(5)); Check.Equal(0, harness.Backend.Writes.Count, "A below-threshold check must not write"); await harness.Controller.RefreshAsync(); Check.Equal(2, harness.Probe.CheckCount, "The merge flag must reset after the running check completes"); } public static async Task SaveAndRefreshAreSerializedAsync() { using var harness = new ControllerHarness(Fixtures.Baseline()); var captureEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var releaseCapture = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var probeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var captures = 0; harness.Backend.CaptureBehavior = async (_, cancellationToken) => { if (Interlocked.Increment(ref captures) == 1) { captureEntered.TrySetResult(); await releaseCapture.Task.WaitAsync(cancellationToken); } return harness.Backend.Current; }; harness.Probe.CheckBehavior = (_, _, _) => { probeEntered.TrySetResult(); return Task.FromResult(Fixtures.Health(proxyHealthy: true, internetHealthy: true)); }; var settings = Fixtures.Settings() with { Policy = ControlPolicy.Paused }; var save = harness.Controller.SaveSettingsAsync(settings); Task? refresh = null; var probeStartedWhileSaveHeldGate = false; var refreshCompletedWhileSaveHeldGate = false; try { await captureEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); refresh = harness.Controller.RefreshAsync(); var earlyWinner = await Task.WhenAny(probeEntered.Task, Task.Delay(TimeSpan.FromMilliseconds(150))); probeStartedWhileSaveHeldGate = ReferenceEquals(earlyWinner, probeEntered.Task); refreshCompletedWhileSaveHeldGate = refresh.IsCompleted; } finally { releaseCapture.TrySetResult(); } await save.WaitAsync(TimeSpan.FromSeconds(5)); if (refresh is not null) await refresh.WaitAsync(TimeSpan.FromSeconds(5)); Check.False(probeStartedWhileSaveHeldGate, "Refresh must wait for the in-progress save"); Check.False(refreshCompletedWhileSaveHeldGate, "Refresh must not complete while another write operation holds the gate"); Check.Equal(1, harness.Probe.CheckCount, "Refresh should run once after the save releases the gate"); Check.Equal(2, harness.Backend.CaptureCount, "Save and the subsequent refresh should capture sequentially"); Check.Equal(0, harness.Backend.Writes.Count, "Neither operation should write in paused mode"); } public static async Task StopRestoresWhenRequestedAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings(); await SwitchToProxyAsync(harness, settings); await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); Check.Equal(2, harness.Backend.Writes.Count, "Proxy setup should be the only network change before stop"); await harness.Controller.StopAsync(); Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit should restore the original direct configuration"); Check.Equal(4, harness.Backend.Writes.Count, "Stop should write baseline routes and DNS once"); Check.Equal(ControlPolicy.Paused, harness.Controller.Status.Policy, "Stop restore should leave automatic control paused"); Check.Equal(ControlPolicy.Paused, harness.Store.Read("settings.json")?.Policy, "Stop restore should persist paused policy"); Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Stop restore should clear its journal"); var session = harness.Store.Read("session.json"); Check.True(session is not null, "Stop restore should persist the updated session"); Check.True(NetworkComparison.SameConfiguration(baseline, session!.Expected), "Stopped session should record the restored configuration"); } public static async Task StopRestoresPersistedSessionBeforeFirstRefreshAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings(); await SwitchToProxyAsync(harness, settings); await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fixture should persist a proxy session before restart"); var restarted = new GatewayController(harness.Backend, harness.Probe, harness.Store, harness.Logs.Add); await restarted.StopAsync(); Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "RestoreOnExit must restore the persisted baseline even before the first refresh"); } public static async Task StopLeavesNetworkWhenRestoreIsDisabledAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings(); await SwitchToProxyAsync(harness, settings); await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = false }); var writesBeforeStop = harness.Backend.Writes.Count; await harness.Controller.StopAsync(); Check.Equal(writesBeforeStop, harness.Backend.Writes.Count, "Stop must not restore when RestoreOnExit is false"); Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "The proxy configuration should remain active"); Check.Equal(false, harness.Store.Read("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled"); Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "A normal stop should leave no journal"); } public static async Task StopRestoreFailureLeavesControllerAvailableAsync() { var baseline = Fixtures.Baseline(); using var harness = new ControllerHarness(baseline); var settings = Fixtures.Settings(); await SwitchToProxyAsync(harness, settings); await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused, RestoreOnExit = true }); var failedAutoWrite = 0; harness.Backend.DnsWriteBehavior = desired => { if (desired.Automatic && Interlocked.Increment(ref failedAutoWrite) == 1) throw new IOException("Injected restore DNS failure"); harness.Backend.ApplyDns(desired); return Task.CompletedTask; }; await Check.ThrowsAsync( () => harness.Controller.StopAsync(), "A failed restore during stop should be reported"); Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Failed stop restore should compensate to the pre-stop proxy state"); Check.Equal(null, harness.Store.Read(SwitchService.JournalFile), "Successful compensation should clear the journal"); harness.Backend.DnsWriteBehavior = null; var probesBefore = harness.Probe.CheckCount; await harness.Controller.RefreshAsync(); Check.Equal(probesBefore + 1, harness.Probe.CheckCount, "A failed stop restore must leave the controller running"); Check.Equal(NetworkMode.Proxy, harness.Controller.Status.Mode, "Compensation should leave the controller on the proxy configuration"); await harness.Controller.StopAsync(); Check.True(NetworkComparison.SameConfiguration(baseline, harness.Backend.Current), "A retried stop should restore successfully"); } private static async Task SwitchToProxyAsync(ControllerHarness harness, AppSettings settings) { harness.Probe.VerifyResult = true; await harness.Controller.SaveSettingsAsync(settings with { Policy = ControlPolicy.Paused }); await harness.Controller.SetPolicyAsync(ControlPolicy.ForceProxy); Check.Equal("192.168.6.2", harness.Backend.Current.Gateway, "Fake switch should reach the proxy gateway"); Check.Equal(2, harness.Backend.Writes.Count, "Fake proxy setup should write routes and DNS"); } private sealed class ControllerHarness : IDisposable { public ControllerHarness(NetworkSnapshot initial, AppSettings? persistedSettings = null) { Store = new JsonStore(Temp.DirectoryPath); if (persistedSettings is not null) Store.Write("settings.json", persistedSettings); Backend = new FakeBackend(initial); Probe = new FakeProbe(); Controller = new GatewayController(Backend, Probe, Store, Logs.Add); } public TempDirectory Temp { get; } = new(); public FakeBackend Backend { get; } public FakeProbe Probe { get; } public JsonStore Store { get; } public GatewayController Controller { get; } public List Logs { get; } = []; public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name); public void Dispose() => Temp.Dispose(); } }