实现 .NET 10 托盘版网关保活

包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
chuzhongzai
2026-10-23 23:35:20 +08:00
parent 59a2982b2c
commit 2d85527656
57 changed files with 8623 additions and 0 deletions
@@ -0,0 +1,354 @@
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<SessionState>("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<AppSettings>("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<AppSettings>("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<IOException>(
() => harness.Controller.SaveSettingsAsync(uncommitted),
"A settings write failure should fail the save");
Check.True(harness.Store.Read<SessionState>("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<AppSettings>("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<SwitchJournal>(SwitchService.JournalFile) is not null, "Automatic refresh must retain the recovery journal");
Check.Equal(ControlPolicy.Auto, harness.Store.Read<AppSettings>("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<IOException>(
() => 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<AppSettings>("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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<AppSettings>("settings.json")?.Policy, "Stop restore should persist paused policy");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Stop restore should clear its journal");
var session = harness.Store.Read<SessionState>("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<AppSettings>("settings.json")?.RestoreOnExit, "RestoreOnExit should remain disabled");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<IOException>(
() => 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<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(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<string> Logs { get; } = [];
public string FilePath(string name) => Path.Combine(Temp.DirectoryPath, name);
public void Dispose() => Temp.Dispose();
}
}
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/GatewayKeepalive.Core/GatewayKeepalive.Core.csproj" />
<ProjectReference Include="../../src/GatewayKeepalive.Windows/GatewayKeepalive.Windows.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class JsonStoreTests
{
public static async Task AtomicReplacementAsync()
{
using var temp = new TempDirectory();
var nested = Path.Combine(temp.DirectoryPath, "nested");
var store = new JsonStore(nested);
var first = new SwitchJournal(
Fixtures.Baseline(),
Fixtures.Baseline() with { Dns = new DnsConfiguration(false, ["192.168.6.2"], []) },
"Prepared",
new DateTimeOffset(2026, 2, 3, 4, 5, 6, TimeSpan.Zero));
const string file = "switch-journal.json";
store.Write(file, first);
var read = store.Read<SwitchJournal>(file);
Check.True(read is not null, "A written journal should be readable");
Check.Equal(first.Stage, read!.Stage, "String data should round trip");
Check.Equal(first.StartedAt, read.StartedAt, "DateTimeOffset should round trip");
Check.True(NetworkComparison.SameConfiguration(first.Before, read.Before), "Nested before snapshot should round trip");
Check.True(NetworkComparison.SameConfiguration(first.Intended, read.Intended), "Nested intended snapshot should round trip");
Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Successful write must clean its temporary file");
var second = first with { Stage = "Verified" };
store.Write(file, second);
Check.Equal("Verified", store.Read<SwitchJournal>(file)?.Stage, "The target should contain the replacement");
Check.Equal("Prepared", store.Read<SwitchJournal>(file + ".bak")?.Stage, "The previous complete file should be retained as a backup");
Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "Replacement must clean its temporary file");
var beforeFailure = File.ReadAllText(Path.Combine(nested, file));
var backupBeforeFailure = File.ReadAllText(Path.Combine(nested, file + ".bak"));
var failure = await Check.ThrowsAnyAsync(
() => Task.Run(() => store.Write(file, new UnserializablePayload())),
"A serialization failure should be observable");
Check.Contains("intentional serialization fault", failure.ToString(), "The expected serialization fault should surface");
Check.Equal(beforeFailure, File.ReadAllText(Path.Combine(nested, file)), "A failed write must leave the target byte-for-byte intact");
Check.Equal(backupBeforeFailure, File.ReadAllText(Path.Combine(nested, file + ".bak")), "A failed write must leave the backup intact");
Check.Equal(0, Directory.GetFiles(nested, "*.tmp").Length, "A failed write must delete its temporary file");
var empty = Path.Combine(nested, "empty.json");
File.WriteAllText(empty, string.Empty);
Check.Throws<System.Text.Json.JsonException>(() => store.Read<SwitchJournal>("empty.json"), "Malformed JSON must be rejected");
}
private sealed class UnserializablePayload
{
public string Value => throw new InvalidOperationException("intentional serialization fault");
}
}
@@ -0,0 +1,26 @@
using GatewayKeepalive.Windows;
namespace GatewayKeepalive.Tests;
internal static class NativeLayoutTests
{
public static Task X64LayoutAsync()
{
Check.Equal(8, IntPtr.Size, "Native layout assertions require the x64 test host");
var layout = WindowsNetworkBackend.NativeLayout();
var properties = layout.GetType().GetProperties().ToDictionary(property => property.Name, property => property.GetValue(layout));
int Read(string name)
{
Check.True(properties.TryGetValue(name, out var value), $"NativeLayout should expose {name}");
return (int)value!;
}
Check.Equal(104, Read("RouteSize"), "MIB_IPFORWARD_ROW2 x64 size");
Check.Equal(168, Read("InterfaceSize"), "MIB_IPINTERFACE_ROW x64 size");
Check.Equal(64, Read("DnsSize"), "DNS_SETTINGS x64 size");
Check.Equal(84, Read("RouteMetricOffset"), "MIB_IPFORWARD_ROW2 metric offset");
Check.Equal(148, Read("InterfaceMetricOffset"), "MIB_IPINTERFACE_ROW metric offset");
return Task.CompletedTask;
}
}
@@ -0,0 +1,96 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class NetworkComparisonTests
{
public static Task EachOwnedFieldParticipatesAsync()
{
var baseline = Fixtures.Baseline();
var route = baseline.Routes[0];
Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { CapturedAt = baseline.CapturedAt.AddMinutes(1) }), "Capture time is observational metadata, not network configuration");
Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { EffectiveDns = ["192.168.6.254"] }), "Effective DNS is derived state; configured DNS owns the snapshot");
Check.True(NetworkComparison.SameConfiguration(baseline, baseline with { Routes = [route] }), "Identical route rows must compare equal");
foreach (var changed in new[]
{
baseline with { Routes = [route with { Metric = route.Metric + 1 }] },
baseline with { Routes = [route with { Protocol = route.Protocol + 1 }] },
baseline with { Routes = [route with { Publish = !route.Publish }] },
baseline with { Routes = [route with { Immortal = !route.Immortal }] },
baseline with { Routes = [route with { InterfaceLuid = route.InterfaceLuid + 1 }] },
baseline with { Routes = [route with { InterfaceIndex = route.InterfaceIndex + 1 }] },
baseline with { Routes = [route with { Gateway = "192.168.6.2" }] },
baseline with { Dns = new DnsConfiguration(Automatic: false, Servers: [], ProfileServers: []) },
baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: ["192.168.6.1"], ProfileServers: []) },
baseline with { Dns = new DnsConfiguration(Automatic: true, Servers: [], ProfileServers: ["192.168.6.1"]) },
baseline with { Adapter = baseline.Adapter with { Id = Guid.NewGuid() } },
baseline with { Adapter = baseline.Adapter with { Index = baseline.Adapter.Index + 1 } },
baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = "network-b" } },
baseline with { Adapter = baseline.Adapter with { NetworkFingerprint = " " } },
})
{
Check.False(NetworkComparison.SameConfiguration(baseline, changed), "Every identity, route ownership field, and DNS component must participate in equality");
}
Check.False(
NetworkComparison.SameRoutes([route], [route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)]),
"An added route must change the owned route set");
Check.True(
NetworkComparison.SameRoutes(
[route, new RouteEntry("192.168.6.2", 1, 0x1000, 7)],
[new RouteEntry("192.168.6.2", 1, 0x1000, 7), route]),
"Route order should not matter");
return Task.CompletedTask;
}
public static Task RecoveryRejectsForeignRowsAsync()
{
var before = Fixtures.Baseline();
var direct = before.Routes[0];
var proxy = new RouteEntry("192.168.6.2", 1, direct.InterfaceLuid, direct.InterfaceIndex);
var intended = before with
{
Routes = [direct with { Metric = 101 }, proxy],
Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.2"], ProfileServers: []),
};
var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow);
Check.True(NetworkComparison.BelongsToTransaction(journal, before), "The untouched pre-switch state belongs to the transaction");
Check.True(NetworkComparison.BelongsToTransaction(journal, intended), "The fully intended state belongs to the transaction");
Check.True(
NetworkComparison.BelongsToTransaction(journal, before with { Routes = intended.Routes }),
"Routes may be applied while DNS still has its prior value");
Check.True(
NetworkComparison.BelongsToTransaction(journal, before with { Dns = intended.Dns }),
"DNS may be applied while routes still have their prior value");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct with { Metric = 50 }, proxy] }),
"A same-gateway row with a foreign metric must not be treated as owned");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Routes = [direct, proxy with { Metric = 2 }] }),
"A same-gateway row with a foreign proxy metric must not be treated as owned");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Routes = [proxy] }),
"A missing row required by both before and intended must stop recovery");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Routes = [.. intended.Routes, new RouteEntry("10.1.2.3", 1, 0x2000, 8)] }),
"A foreign route added by another tool must stop recovery");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Dns = new DnsConfiguration(false, ["10.1.2.53"], []) }),
"Foreign DNS must stop recovery");
Check.False(
NetworkComparison.BelongsToTransaction(journal, before with { Adapter = before.Adapter with { NetworkFingerprint = "network-b" } }),
"A different network must stop recovery");
var reordered = before with
{
Routes = [proxy, direct with { Metric = 101 }],
Dns = intended.Dns,
};
Check.True(NetworkComparison.BelongsToTransaction(journal, reordered), "Owned route rows should be compared independently of order");
return Task.CompletedTask;
}
}
+77
View File
@@ -0,0 +1,77 @@
namespace GatewayKeepalive.Tests;
internal static class Program
{
private static async Task<int> Main()
{
var tests = new (string Name, Func<Task> Run)[]
{
("SwitchPolicy: success threshold, cap, and recovery cooldown", SwitchPolicyTests.SuccessThresholdAndCooldownAsync),
("SwitchPolicy: failure threshold and failure cooldown", SwitchPolicyTests.FailureThresholdAndCooldownAsync),
("SwitchPolicy: failure retry has a 30 second floor", SwitchPolicyTests.FailureRetryUsesThirtySecondFloorAsync),
("SwitchPolicy: paused, fixed, and observe-only modes reset counts", SwitchPolicyTests.NonAutoModesResetCountsAsync),
("SwitchPolicy: switch, failure, and explicit reset clear counters", SwitchPolicyTests.CountsResetAfterEventsAsync),
("SwitchService: DNS failure after route write rolls back", SwitchServiceTests.DnsFailureAfterRouteWriteRollsBackAsync),
("SwitchService: egress verification failure rolls back", SwitchServiceTests.EgressFailureRollsBackAsync),
("SwitchService: rollback failure retains journal and raises RecoveryRequired", SwitchServiceTests.RollbackFailureRetainsJournalAsync),
("SwitchService: external modification is never overwritten", SwitchServiceTests.ExternalModificationIsNotOverwrittenAsync),
("SwitchService: cross-network rollback is rejected", SwitchServiceTests.CrossNetworkRollbackIsRejectedAsync),
("SwitchService: profile DNS is rejected", SwitchServiceTests.ProfileDnsIsRejectedAsync),
("SwitchService: destination gateway must be on the adapter subnet", SwitchServiceTests.GatewayMustBeOnAdapterSubnetAsync),
("SwitchService: failed baseline restore compensates original configuration", SwitchServiceTests.BaselineRestoreFailureCompensatesAsync),
("SwitchService: plan preserves an already preferred gateway metric", SwitchServiceTests.PlanPreservesPreferredMetricAsync),
("SwitchService: identical configuration skips route and DNS writes", SwitchServiceTests.SwitchSameConfigurationSkipsWritesAsync),
("SwitchService: only changed DNS is written", SwitchServiceTests.SwitchWritesOnlyChangedDnsAsync),
("SwitchService: only changed routes are written", SwitchServiceTests.SwitchWritesOnlyChangedRoutesAsync),
("SwitchService: unchanged rollback skips route and DNS writes", SwitchServiceTests.RollbackSameConfigurationSkipsWritesAsync),
("SwitchService: unchanged baseline restore skips route and DNS writes", SwitchServiceTests.BaselineRestoreSameConfigurationSkipsWritesAsync),
("GatewayController: unconfigured refresh does no probe or write", GatewayControllerTests.InitialUnconfiguredDoesNotProbeOrWriteAsync),
("GatewayController: first paused save preserves real direct DNS", GatewayControllerTests.ExplicitPausedSavePreservesRealDirectDnsAsync),
("GatewayController: external configuration is not overwritten", GatewayControllerTests.ExternalConfigurationChangeIsNotOverwrittenAsync),
("GatewayController: legacy empty session fingerprint is not writable", GatewayControllerTests.LegacySessionWithoutFingerprintIsNotWritableAsync),
("GatewayController: failed save does not become writable", GatewayControllerTests.SaveFailureAfterSessionWriteDoesNotBecomeWritableAsync),
("GatewayController: unfinished journal blocks automatic control", GatewayControllerTests.UnfinishedJournalBlocksAutomaticControlAsync),
("GatewayController: fixed rollback failure keeps fixed policy", GatewayControllerTests.FixedFailureKeepsFixedPolicyAfterRollbackAsync),
("GatewayController: concurrent refreshes are coalesced", GatewayControllerTests.ConcurrentRefreshIsCoalescedAsync),
("GatewayController: save and refresh writes are serialized", GatewayControllerTests.SaveAndRefreshAreSerializedAsync),
("GatewayController: stop restores when RestoreOnExit is enabled", GatewayControllerTests.StopRestoresWhenRequestedAsync),
("GatewayController: stop restores persisted session before first refresh", GatewayControllerTests.StopRestoresPersistedSessionBeforeFirstRefreshAsync),
("GatewayController: stop leaves network when restore is disabled", GatewayControllerTests.StopLeavesNetworkWhenRestoreIsDisabledAsync),
("GatewayController: failed stop restore leaves controller available", GatewayControllerTests.StopRestoreFailureLeavesControllerAvailableAsync),
("Recovery: failed durable Pause prevents all restore writes", RecoveryRegressionTests.RestorePauseWriteFailurePreventsNetworkWritesAsync),
("Recovery: Pause precedes network restore and survives restart", RecoveryRegressionTests.RestorePersistsPauseBeforeWritesAndRestartAsync),
("Recovery: baseline metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsBaselineJournalAsync),
("Recovery: explicit rollback metadata failure retains journal and durable Pause", RecoveryRegressionTests.RestoreSessionFailureRetainsRollbackJournalAsync),
("Recovery: Stop resolves pending journal then restores original baseline", RecoveryRegressionTests.StopPendingJournalContinuesToOriginalBaselineAsync),
("Guarded switch: foreign route after preflight is preserved", RecoveryRegressionTests.ForeignRouteAfterPreflightIsProtectedAsync),
("Guarded switch: foreign DNS after preflight is preserved", RecoveryRegressionTests.ForeignDnsAfterPreflightIsProtectedAsync),
("Guarded switch: foreign route after routes and before DNS is preserved", RecoveryRegressionTests.ForeignRouteBeforeDnsIsProtectedAsync),
("Guarded switch: foreign DNS after routes and before DNS is preserved", RecoveryRegressionTests.ForeignDnsBeforeDnsIsProtectedAsync),
("NetworkComparison: every owned route, DNS, and network field participates", NetworkComparisonTests.EachOwnedFieldParticipatesAsync),
("NetworkComparison: transaction recovery accepts only exact before/intended rows", NetworkComparisonTests.RecoveryRejectsForeignRowsAsync),
("JsonStore: round trip, backup, and failed serialization preserve the last complete file", JsonStoreTests.AtomicReplacementAsync),
("Windows NativeLayout: x64 structure sizes and metric offsets", NativeLayoutTests.X64LayoutAsync),
};
var failures = 0;
var started = DateTime.UtcNow;
Console.WriteLine($"Running {tests.Length} offline GatewayKeepalive tests.");
foreach (var test in tests)
{
try
{
await test.Run();
Console.WriteLine($"PASS {test.Name}");
}
catch (Exception exception)
{
failures++;
Console.WriteLine($"FAIL {test.Name}");
Console.WriteLine(exception);
}
}
Console.WriteLine($"Completed in {(DateTime.UtcNow - started).TotalSeconds:F2}s: {tests.Length - failures} passed, {failures} failed.");
return failures == 0 ? 0 : 1;
}
}
@@ -0,0 +1,216 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class RecoveryRegressionTests
{
public static async Task RestorePauseWriteFailurePreventsNetworkWritesAsync()
{
using var h = new Harness();
await h.AdoptProxyAsync();
var before = h.Backend.Current;
var sessionBytes = File.ReadAllBytes(h.PathFor("session.json"));
File.Delete(h.PathFor("settings.json"));
Directory.CreateDirectory(h.PathFor("settings.json"));
h.Backend.Writes.Clear();
await Check.ThrowsAsync<IOException>(() => h.Controller.RestoreAsync(), "Pause persistence failure must abort restore");
Check.Equal(0, h.Backend.Writes.Count, "No network operation may precede durable Pause");
Check.True(NetworkComparison.SameConfiguration(before, h.Backend.Current), "Failed Pause write must preserve the entire live configuration");
Check.SequenceEqual(sessionBytes, File.ReadAllBytes(h.PathFor("session.json")), "Failed Pause must not update session metadata");
Check.Equal<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Failed Pause must not start a restore transaction");
}
public static async Task RestorePersistsPauseBeforeWritesAndRestartAsync()
{
using var h = new Harness();
await h.AdoptProxyAsync();
h.Backend.Writes.Clear();
h.Backend.RouteWriteBehavior = desired =>
{
Check.Equal(ControlPolicy.Paused, h.Store.Read<AppSettings>("settings.json")?.Policy, "Pause must be durable before the first route write");
h.Backend.ApplyRoutes(desired);
return Task.CompletedTask;
};
h.Backend.DnsWriteBehavior = desired =>
{
Check.Equal(ControlPolicy.Paused, h.Store.Read<AppSettings>("settings.json")?.Policy, "Pause must be durable before DNS writes");
h.Backend.ApplyDns(desired);
return Task.CompletedTask;
};
await h.Controller.RestoreAsync();
Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Restore must reach the exact original baseline");
Check.Equal<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Committed restore should clear its journal");
var writes = h.Backend.Writes.Count;
var restarted = h.Restart();
await restarted.RefreshAsync();
Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart must load Pause rather than the former Auto policy");
Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Healthy recovery candidates must remain paused after restart");
Check.Equal(writes, h.Backend.Writes.Count, "Restart must not automatically undo a successful restore");
}
public static Task RestoreSessionFailureRetainsBaselineJournalAsync() => RestoreSessionFailureAsync(false);
public static Task RestoreSessionFailureRetainsRollbackJournalAsync() => RestoreSessionFailureAsync(true);
private static async Task RestoreSessionFailureAsync(bool pendingJournal)
{
using var h = new Harness();
await h.AdoptProxyAsync();
var proxy = h.Backend.Current;
if (pendingJournal)
{
// A failed direct -> proxy transaction must roll back to the original direct state.
h.Store.Write(SwitchService.JournalFile, new SwitchJournal(h.Baseline, proxy, "ApplyingDns", DateTimeOffset.UtcNow));
}
var oldSessionBytes = File.ReadAllBytes(h.PathFor("session.json"));
var blockedMetadata = false;
h.Backend.CaptureBehavior = (_, _) =>
{
if (!blockedMetadata && NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current))
{
blockedMetadata = true;
File.Delete(h.PathFor("session.json"));
Directory.CreateDirectory(h.PathFor("session.json"));
}
return Task.FromResult(h.Backend.Current);
};
await Check.ThrowsAsync<IOException>(() => h.Controller.RestoreAsync(), "A session commit failure should be observable");
Check.True(blockedMetadata, "The injected failure must occur after network restoration");
Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "The network must already be at the restored baseline");
Check.Equal(ControlPolicy.Paused, h.Store.Read<AppSettings>("settings.json")?.Policy, "Session failure must leave Pause durable");
var journal = h.Store.Read<SwitchJournal>(SwitchService.JournalFile);
Check.True(journal is not null, "Uncommitted session metadata must retain its recovery journal");
Check.Equal(pendingJournal ? "RolledBack" : "Verified", journal!.Stage, "The journal must record the completed network phase until session commit");
// Simulate a restart seeing the last durable session rather than the uncommitted one.
Directory.Delete(h.PathFor("session.json"));
File.WriteAllBytes(h.PathFor("session.json"), oldSessionBytes);
h.Backend.CaptureBehavior = null;
var writes = h.Backend.Writes.Count;
var restarted = h.Restart();
await restarted.RefreshAsync();
Check.Equal(ControlPolicy.Paused, restarted.Settings.Policy, "Restart after a metadata failure must load Pause");
Check.Equal(NetworkMode.RecoveryRequired, restarted.Status.Mode, "Uncommitted restoration must require explicit recovery");
Check.Equal(writes, h.Backend.Writes.Count, "Restart must protect the restored state from Auto");
Check.True(h.Store.Read<SwitchJournal>(SwitchService.JournalFile) is not null, "Refresh must retain the uncommitted journal");
}
public static async Task StopPendingJournalContinuesToOriginalBaselineAsync()
{
using var h = new Harness();
await h.AdoptProxyAsync(restoreOnExit: true);
var proxy = h.Backend.Current;
// Recovery first returns to Before=Proxy. Stop must then restore Baseline=Direct.
h.Store.Write(SwitchService.JournalFile, new SwitchJournal(proxy, h.Baseline, "Verified", DateTimeOffset.UtcNow));
h.Backend.Current = h.Baseline;
h.Backend.Writes.Clear();
var restarted = h.Restart();
await restarted.StopAsync();
Check.True(NetworkComparison.SameConfiguration(h.Baseline, h.Backend.Current), "Stop must continue beyond journal.Before=Proxy to the original direct baseline");
Check.Equal(ControlPolicy.Paused, h.Store.Read<AppSettings>("settings.json")?.Policy, "Stop must persist Pause");
Check.Equal(ControlPolicy.Paused, restarted.Status.Policy, "Stop must publish paused state");
var session = h.Store.Read<SessionState>("session.json");
Check.True(session is not null, "Stop must persist the final session");
Check.True(NetworkComparison.SameConfiguration(h.Baseline, session!.Expected), "Final session must describe the direct baseline");
Check.True(NetworkComparison.SameConfiguration(h.Baseline, session.Baseline), "Original baseline must survive journal recovery");
Check.Equal<SwitchJournal?>(null, h.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Both recovery phases must commit before clearing the final journal");
Check.SequenceEqual(
[new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")],
h.Backend.Writes, "Stop should first recover the pending transaction and then restore its baseline");
}
public static Task ForeignRouteAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: false);
public static Task ForeignDnsAfterPreflightIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: false, changeDns: true);
public static Task ForeignRouteBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: false);
public static Task ForeignDnsBeforeDnsIsProtectedAsync() => ForeignChangeDuringSwitchAsync(afterRoutes: true, changeDns: true);
private static async Task ForeignChangeDuringSwitchAsync(bool afterRoutes, bool changeDns)
{
var before = Fixtures.Baseline();
using var h = new SwitchHarness(before);
var foreignRoute = before.Routes[0] with { Gateway = "192.168.6.254", Metric = 0 };
var foreignDns = new DnsConfiguration(false, ["192.168.6.253"], []);
NetworkSnapshot? foreignState = null;
void ChangeExternally()
{
h.Backend.Current = changeDns
? h.Backend.Current with { Dns = foreignDns }
: h.Backend.Current with { Routes = [.. h.Backend.Current.Routes, foreignRoute] };
foreignState = h.Backend.Current;
}
if (afterRoutes)
{
h.Backend.RouteWriteBehavior = desired =>
{
h.Backend.ApplyRoutes(desired);
ChangeExternally();
return Task.CompletedTask;
};
}
else
{
h.Backend.CaptureBehavior = (_, _) =>
{
if (h.Backend.CaptureCount == 2) ChangeExternally();
return Task.FromResult(h.Backend.Current);
};
}
var failure = await Check.ThrowsAsync<RecoveryRequiredException>(
() => h.Service.SwitchAsync(Fixtures.Settings(), before, true, null, CancellationToken.None),
"A foreign change after preflight must stop guarded writes and require recovery");
Check.True(foreignState is not null, "The external change must actually be injected");
Check.True(NetworkComparison.SameConfiguration(foreignState!, h.Backend.Current), "Forward and rollback paths must preserve the complete foreign configuration");
Check.SequenceEqual(afterRoutes ? [new BackendWrite("route", "192.168.6.2")] : Array.Empty<BackendWrite>(), h.Backend.Writes,
"No DNS or compensation write may overwrite the foreign configuration");
var journal = h.Store.Read<SwitchJournal>(SwitchService.JournalFile);
Check.True(journal is not null, "Foreign changes must leave a journal for explicit recovery");
Check.Equal(afterRoutes ? "ApplyingDns" : "ApplyingRoutes", journal!.Stage, "The journal must identify the blocked phase");
Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "The journal must preserve the genuine pre-transaction snapshot");
Check.True(failure.InnerException is AggregateException, "Both guarded apply and ownership refusal must be retained");
Check.Equal(0, h.Probe.VerifyCount, "Rejected guarded writes must not reach egress verification");
}
private sealed class Harness : IDisposable
{
public TempDirectory Temp { get; } = new();
public NetworkSnapshot Baseline { get; } = Fixtures.Baseline();
public FakeBackend Backend { get; }
public FakeProbe Probe { get; } = new();
public JsonStore Store { get; }
public GatewayController Controller { get; }
public List<string> Logs { get; } = [];
public Harness()
{
Backend = new FakeBackend(Baseline);
Store = new JsonStore(Temp.DirectoryPath);
Controller = Restart();
}
public string PathFor(string name) => Path.Combine(Temp.DirectoryPath, name);
public GatewayController Restart() => new(Backend, Probe, Store, Logs.Add);
public async Task AdoptProxyAsync(bool restoreOnExit = false)
{
await Controller.SaveSettingsAsync(Fixtures.Settings() with
{
Policy = ControlPolicy.Paused, RestoreOnExit = restoreOnExit,
SuccessThreshold = 1, RecoveryCooldownSeconds = 0,
});
await Controller.SetPolicyAsync(ControlPolicy.ForceProxy);
await Controller.SetPolicyAsync(ControlPolicy.Auto);
Check.Equal(ControlPolicy.Auto, Store.Read<AppSettings>("settings.json")?.Policy, "Regression setup must begin with durable Auto");
}
public void Dispose() => Temp.Dispose();
}
}
@@ -0,0 +1,155 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class SwitchPolicyTests
{
public static Task SuccessThresholdAndCooldownAsync()
{
var clock = new ManualTimeProvider();
var policy = new SwitchPolicy(clock);
var settings = Fixtures.Settings() with { SuccessThreshold = 3, RecoveryCooldownSeconds = 10 };
var healthy = Fixtures.Health(true, true);
policy.Switched();
Check.Equal(false, policy.Evaluate(settings, NetworkMode.Direct, healthy) is not null, "The first success must remain below threshold");
Check.Equal(1, policy.SuccessCount, "One success should be counted");
clock.AdvanceSeconds(5);
Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The second success must remain below threshold");
clock.AdvanceSeconds(4);
Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "The recovery cooldown must still block at 9 seconds");
Check.Equal(3, policy.SuccessCount, "Success count should cap at the configured threshold");
clock.AdvanceSeconds(1);
Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "The switch must fire exactly when the recovery cooldown expires");
var resetting = new SwitchPolicy(clock);
Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First healthy observation should not switch");
Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second healthy observation should not switch");
Check.True(resetting.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(false, true)) is null, "An unhealthy observation must reset consecutive success");
Check.Equal(0, resetting.SuccessCount, "Unhealthy observation should reset success count");
Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "First post-reset success must not switch");
Check.True(resetting.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Second post-reset success must not switch");
Check.Equal(2, resetting.SuccessCount, "Only consecutive successes should count");
return Task.CompletedTask;
}
public static Task FailureThresholdAndCooldownAsync()
{
var clock = new ManualTimeProvider();
var policy = new SwitchPolicy(clock);
var settings = Fixtures.Settings() with
{
FailureThreshold = 3,
FailureCooldownSeconds = 5,
RecoveryCooldownSeconds = 3600,
};
policy.Switched();
var unhealthy = Fixtures.Health(true, false);
Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "First failed probe must not switch");
clock.AdvanceSeconds(1);
Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Second failed probe must not switch");
clock.AdvanceSeconds(3);
Check.True(policy.Evaluate(settings, NetworkMode.Proxy, unhealthy) is null, "Failure cooldown must block before 5 seconds");
Check.Equal(3, policy.FailureCount, "Failure count should cap at threshold");
clock.AdvanceSeconds(1);
Check.Equal(false, policy.Evaluate(settings, NetworkMode.Proxy, unhealthy), "Proxy must fail over to direct at its own cooldown");
var recoveryClock = new ManualTimeProvider();
var recovery = new SwitchPolicy(recoveryClock);
var recoverySettings = settings with { SuccessThreshold = 1, RecoveryCooldownSeconds = 40, FailureCooldownSeconds = 3600 };
recovery.Switched();
recoveryClock.AdvanceSeconds(39);
Check.True(recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)) is null, "Recovery cooldown must be independent of failure cooldown");
recoveryClock.AdvanceSeconds(1);
Check.Equal(true, recovery.Evaluate(recoverySettings, NetworkMode.Direct, Fixtures.Health(true, true)), "Direct should recover at the configured recovery cooldown");
var resetting = new SwitchPolicy(new ManualTimeProvider());
resetting.Switched();
resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy);
resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy);
Check.Equal(2, resetting.FailureCount, "Failure fixture should be consecutive");
Check.True(resetting.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(true, true)) is null, "A healthy proxy check must not switch");
Check.Equal(0, resetting.FailureCount, "A healthy proxy check must reset failure count");
resetting.Evaluate(settings, NetworkMode.Proxy, unhealthy);
Check.Equal(1, resetting.FailureCount, "Only consecutive failures should count after a healthy check");
return Task.CompletedTask;
}
public static Task FailureRetryUsesThirtySecondFloorAsync()
{
var clock = new ManualTimeProvider();
var policy = new SwitchPolicy(clock);
var settings = Fixtures.Settings() with { SuccessThreshold = 1, RecoveryCooldownSeconds = 0 };
var healthy = Fixtures.Health(true, true);
policy.Switched();
clock.AdvanceSeconds(1);
policy.Failed();
Check.Equal(0, policy.SuccessCount, "Failed() should reset success count");
Check.Equal(0, policy.FailureCount, "Failed() should reset failure count");
clock.AdvanceSeconds(29);
Check.True(policy.Evaluate(settings, NetworkMode.Direct, healthy) is null, "Recovery must remain blocked 29 seconds after a failure");
Check.Equal(1, policy.SuccessCount, "The blocked attempt should still be observed");
clock.AdvanceSeconds(1);
Check.Equal(true, policy.Evaluate(settings, NetworkMode.Direct, healthy), "Recovery must become available at the 30 second failure backoff");
var failureClock = new ManualTimeProvider();
var failurePolicy = new SwitchPolicy(failureClock);
var failureSettings = settings with { FailureThreshold = 1, FailureCooldownSeconds = 0 };
failurePolicy.Failed();
failureClock.AdvanceSeconds(29);
Check.True(failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)) is null, "Failover must also wait 29 seconds");
failureClock.AdvanceSeconds(1);
Check.Equal(false, failurePolicy.Evaluate(failureSettings, NetworkMode.Proxy, Fixtures.Health(false, true)), "Failover must be allowed at the 30 second floor");
return Task.CompletedTask;
}
public static Task NonAutoModesResetCountsAsync()
{
var policy = new SwitchPolicy(new ManualTimeProvider());
var settings = Fixtures.Settings();
var healthy = Fixtures.Health(true, true);
policy.Evaluate(settings, NetworkMode.Direct, healthy);
policy.Evaluate(settings, NetworkMode.Direct, healthy);
Check.Equal(2, policy.SuccessCount, "Fixture should accumulate success before changing mode");
foreach (var mode in new[] { ControlPolicy.Paused, ControlPolicy.ForceDirect, ControlPolicy.ForceProxy })
{
Check.True(policy.Evaluate(settings with { Policy = mode }, NetworkMode.Direct, healthy) is null, $"{mode} must request no automatic switch");
Check.Equal(0, policy.SuccessCount, $"{mode} must clear success count");
Check.Equal(0, policy.FailureCount, $"{mode} must clear failure count");
}
policy.Evaluate(settings, NetworkMode.Proxy, Fixtures.Health(false, true));
Check.True(policy.Evaluate(settings, NetworkMode.Degraded, healthy) is null, "Observe-only network modes must not switch");
Check.Equal(0, policy.FailureCount, "Observe-only modes must clear failure count");
return Task.CompletedTask;
}
public static Task CountsResetAfterEventsAsync()
{
var clock = new ManualTimeProvider();
var policy = new SwitchPolicy(clock);
var settings = Fixtures.Settings();
var sick = Fixtures.Health(true, false);
policy.Evaluate(settings, NetworkMode.Proxy, sick);
policy.Evaluate(settings, NetworkMode.Proxy, sick);
Check.Equal(2, policy.FailureCount, "Failure count fixture");
policy.Switched();
Check.Equal(0, policy.FailureCount, "Switched() should clear failure count");
Check.Equal(0, policy.SuccessCount, "Switched() should clear success count");
policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true));
policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true));
policy.Failed();
Check.Equal(0, policy.SuccessCount, "Failed() should clear success count");
Check.Equal(0, policy.FailureCount, "Failed() should clear failure count");
policy.Evaluate(settings, NetworkMode.Direct, Fixtures.Health(true, true));
policy.Reset();
Check.Equal(0, policy.SuccessCount, "Reset() should clear success count");
Check.Equal(0, policy.FailureCount, "Reset() should clear failure count");
return Task.CompletedTask;
}
}
@@ -0,0 +1,344 @@
using GatewayKeepalive.Core;
namespace GatewayKeepalive.Tests;
internal static class SwitchServiceTests
{
public static async Task DnsFailureAfterRouteWriteRollsBackAsync()
{
var before = Fixtures.Baseline();
using var harness = new SwitchHarness(before);
harness.Backend.DnsWriteBehavior = desired =>
{
if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal))
throw new IOException("Injected DNS write failure");
harness.Backend.ApplyDns(desired);
return Task.CompletedTask;
};
var failure = await Check.ThrowsAsync<IOException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None),
"A DNS failure must surface as a failed switch");
Check.Contains("已恢复", failure.Message, "Rollback success should be reported");
Check.SequenceEqual(
[new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1")],
harness.Backend.Writes,
"The service must restore the changed route and leave the unchanged DNS alone");
Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The fake adapter should be back to its exact pre-switch configuration");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "A verified rollback must remove the journal");
Check.True(harness.Logs.Any(line => line.Contains("切换失败,开始恢复", StringComparison.Ordinal)), "The failure path should be logged");
Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified rollback should be logged");
Check.Equal(0, harness.Probe.VerifyCount, "Egress verification must not run after DNS failure");
}
public static async Task EgressFailureRollsBackAsync()
{
var before = Fixtures.Baseline();
using var harness = new SwitchHarness(before);
harness.Probe.VerifyResult = false;
var failure = await Check.ThrowsAsync<IOException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None),
"Failed egress verification must fail the switch");
Check.Contains("出口验证", failure.Message, "Egress failure should be identified");
Check.Equal(1, harness.Probe.VerifyCount, "The probe should run once after route and DNS readback");
Check.Equal(false, harness.Probe.VerifyToken.IsCancellationRequested, "The switch budget should still be live during verification");
Check.Equal("192.168.6.2", harness.Probe.VerifiedSnapshot?.Gateway, "Verification should use the applied proxy configuration");
Check.SequenceEqual(
[new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2"), new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto")],
harness.Backend.Writes,
"A failed egress check must restore routes and DNS");
Check.True(NetworkComparison.SameConfiguration(before, harness.Backend.Current), "The adapter must be restored exactly");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Successful rollback must delete the journal");
}
public static async Task RollbackFailureRetainsJournalAsync()
{
var before = Fixtures.Baseline();
using var harness = new SwitchHarness(before);
harness.Backend.DnsWriteBehavior = desired =>
{
if (desired.Servers.Contains("192.168.6.2", StringComparer.Ordinal))
throw new IOException("Injected initial DNS failure");
harness.Backend.ApplyDns(desired);
return Task.CompletedTask;
};
var routeWrites = 0;
harness.Backend.RouteWriteBehavior = desired =>
{
routeWrites++;
if (routeWrites == 2) throw new IOException("Injected rollback route failure");
harness.Backend.ApplyRoutes(desired);
return Task.CompletedTask;
};
var failure = await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None),
"Failed rollback must require recovery");
Check.Contains("恢复未完成", failure.Message, "RecoveryRequired should explain the incomplete rollback");
Check.True(failure.InnerException is AggregateException, "Both the switch and rollback failures should be retained");
var journal = harness.Store.Read<SwitchJournal>(SwitchService.JournalFile);
Check.True(journal is not null, "Failed rollback must retain the journal");
Check.Equal("RollingBack", journal!.Stage, "Journal should remain in the interrupted rollback stage");
Check.True(NetworkComparison.SameConfiguration(before, journal.Before), "Journal must retain the true pre-switch baseline");
Check.Equal(2, routeWrites, "Route write should occur once forward and once for rollback");
}
public static async Task ExternalModificationIsNotOverwrittenAsync()
{
var before = Fixtures.Baseline();
using (var harness = new SwitchHarness(before))
{
var changed = before with
{
Routes = [before.Routes[0] with { Metric = 77 }],
};
harness.Backend.Current = changed;
await Check.ThrowsAsync<InvalidOperationException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None),
"A mismatch against the saved expected snapshot must be rejected");
Check.Equal(0, harness.Backend.Writes.Count, "The service must not write when the expected snapshot is stale");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "No journal should be created when preflight fails");
Check.Equal(77u, harness.Backend.Current.PreferredRoute?.Metric, "The external route change must remain untouched");
}
using (var harness = new SwitchHarness(before))
{
var foreign = new RouteEntry("10.20.30.40", 9, 0x5555, 55);
harness.Backend.DnsWriteBehavior = _ =>
{
harness.Backend.Current = harness.Backend.Current with
{
Routes = harness.Backend.Current.Routes.Append(foreign).ToArray(),
};
throw new IOException("Injected DNS failure after external route change");
};
await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), before, proxy: true, directDns: null, CancellationToken.None),
"A foreign row during recovery must stop rollback");
Check.True(harness.Backend.Current.Routes.Any(route => route == foreign), "Recovery must leave the external route in place");
Check.SequenceEqual([new BackendWrite("route", "192.168.6.2"), new BackendWrite("dns", "192.168.6.2")], harness.Backend.Writes, "Rollback must not write over the foreign configuration");
var journal = harness.Store.Read<SwitchJournal>(SwitchService.JournalFile);
Check.True(journal is not null, "RecoveryRequired must preserve the journal");
Check.Equal("ApplyingDns", journal!.Stage, "Journal should retain the failed apply stage");
}
}
public static async Task CrossNetworkRollbackIsRejectedAsync()
{
var before = Fixtures.Baseline();
using var harness = new SwitchHarness(before);
var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null);
var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow);
harness.Backend.Current = before with
{
Adapter = before.Adapter with { NetworkFingerprint = "network-b" },
};
var failure = await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.RollbackAsync(journal, CancellationToken.None),
"Rollback must reject a different network identity");
Check.Contains("不属于本次切换", failure.Message, "Cross-network rejection should explain ownership failure");
Check.Equal(0, harness.Backend.Writes.Count, "Cross-network recovery must not write routes or DNS");
Check.Equal("network-b", harness.Backend.Current.Adapter.NetworkFingerprint, "The other network must remain untouched");
var blank = before with { Adapter = before.Adapter with { NetworkFingerprint = " " } };
harness.Backend.Current = blank;
await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.RollbackAsync(journal, CancellationToken.None),
"Rollback must reject an unknown network identity");
}
public static async Task ProfileDnsIsRejectedAsync()
{
var baseline = Fixtures.Baseline();
var profiled = baseline with
{
Dns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.1"], ProfileServers: ["192.168.6.99"]),
};
using var harness = new SwitchHarness(profiled);
var failure = await Check.ThrowsAsync<InvalidOperationException>(
() => harness.Service.SwitchAsync(Fixtures.Settings(), profiled, proxy: true, directDns: null, CancellationToken.None),
"Profile DNS must prevent takeover");
Check.Contains("配置文件 DNS", failure.Message, "Rejection should identify profile DNS");
Check.Equal(0, harness.Backend.Writes.Count, "Profile DNS must be rejected before any write");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Profile DNS rejection must not leave a journal");
}
public static Task GatewayMustBeOnAdapterSubnetAsync()
{
var before = Fixtures.Baseline();
using var harness = new SwitchHarness(before);
var settings = Fixtures.Settings();
var remoteProxy = Check.Throws<InvalidOperationException>(
() => harness.Service.Plan(settings with { ProxyGateway = "192.168.7.2" }, before, proxy: true, directDns: null),
"A proxy gateway outside the adapter subnet must be rejected");
Check.Contains("子网", remoteProxy.Message, "Subnet rejection should be specific");
Check.Throws<InvalidOperationException>(
() => harness.Service.Plan(settings with { DirectGateway = "192.168.7.1" }, before, proxy: false, directDns: null),
"A direct gateway outside the adapter subnet must be rejected");
Check.Throws<InvalidOperationException>(
() => harness.Service.Plan(settings with { ProxyGateway = "192.168.6.10" }, before, proxy: true, directDns: null),
"A gateway equal to the local address must be rejected");
var narrow = before with
{
Adapter = before.Adapter with { Addresses = [new Ipv4Address("192.168.6.130", 25)] },
Routes = [before.Routes[0] with { Gateway = "192.168.6.129" }],
};
Check.Throws<InvalidOperationException>(
() => harness.Service.Plan(settings with { DirectGateway = "192.168.6.129", ProxyGateway = "192.168.6.2" }, narrow, proxy: true, directDns: null),
"Subnet validation must honor a non-/24 prefix");
Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only on validation failure");
return Task.CompletedTask;
}
public static async Task BaselineRestoreFailureCompensatesAsync()
{
var original = Fixtures.Baseline();
using var harness = new SwitchHarness(original);
var current = harness.Service.Plan(Fixtures.Settings(), original, proxy: true, directDns: null);
harness.Backend.Current = current;
var dnsWrites = 0;
harness.Backend.DnsWriteBehavior = desired =>
{
dnsWrites++;
if (dnsWrites == 1) throw new IOException("Injected baseline DNS failure");
harness.Backend.ApplyDns(desired);
return Task.CompletedTask;
};
var failure = await Check.ThrowsAsync<IOException>(
() => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None),
"A failed baseline restore must report the compensation result");
Check.Contains("已退回", failure.Message, "Successful compensation should be explicit");
Check.SequenceEqual(
[new BackendWrite("route", "192.168.6.1"), new BackendWrite("dns", "auto"), new BackendWrite("route", "192.168.6.2")],
harness.Backend.Writes,
"Baseline failure must restore the changed route and leave unchanged proxy DNS alone");
Check.True(NetworkComparison.SameConfiguration(current, harness.Backend.Current), "Compensation must restore the exact pre-restore configuration");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Verified compensation must remove the restore journal");
Check.True(harness.Logs.Any(line => line.Contains("切换前配置已恢复并读回验证", StringComparison.Ordinal)), "Verified baseline compensation should be logged");
var foreign = current with
{
Routes = current.Routes.Append(new RouteEntry("10.9.8.7", 3, 0x9999, 99)).ToArray(),
};
harness.Backend.Current = foreign;
var writesBefore = harness.Backend.Writes.Count;
await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.RestoreBaselineAsync(current, original, CancellationToken.None),
"Baseline restore must reject an externally modified live configuration");
Check.Equal(writesBefore, harness.Backend.Writes.Count, "Rejected baseline restore must not overwrite the external configuration");
Check.True(harness.Backend.Current.Routes.Any(route => route.Gateway == "10.9.8.7"), "External baseline modification should remain");
var otherNetwork = original with { Adapter = original.Adapter with { NetworkFingerprint = "network-b" } };
await Check.ThrowsAsync<RecoveryRequiredException>(
() => harness.Service.RestoreBaselineAsync(current, otherNetwork, CancellationToken.None),
"A baseline from another network must be rejected");
}
public static Task PlanPreservesPreferredMetricAsync()
{
var route = Fixtures.Baseline().Routes[0] with { Metric = 0 };
var preferred = Fixtures.Baseline() with { Routes = [route] };
using var harness = new SwitchHarness(preferred);
var settings = Fixtures.Settings();
var directPlan = harness.Service.Plan(settings, preferred, proxy: false, directDns: null);
Check.Equal(0u, directPlan.PreferredRoute?.Metric, "An already preferred gateway must retain its DHCP metric of zero");
Check.True(NetworkComparison.SameConfiguration(preferred, directPlan), "A no-op direct plan must preserve route and DNS configuration");
Check.Equal(0, harness.Backend.Writes.Count, "Plan must remain read-only");
var proxyPlan = harness.Service.Plan(settings, preferred, proxy: true, directDns: null);
Check.Equal("192.168.6.2", proxyPlan.Gateway, "A non-preferred target should become preferred");
Check.Equal(1u, proxyPlan.PreferredRoute?.Metric, "The new proxy route should use the preferred metric");
Check.Equal(101u, proxyPlan.Routes.Single(x => x.Gateway == "192.168.6.1").Metric, "The previous route should be demoted relative to the new preferred route");
return Task.CompletedTask;
}
public static async Task SwitchSameConfigurationSkipsWritesAsync()
{
var route = Fixtures.Baseline().Routes[0] with { Metric = 0 };
var before = Fixtures.Baseline() with { Routes = [route] };
using var harness = new SwitchHarness(before);
var actual = await harness.Service.SwitchAsync(
Fixtures.Settings(), before, proxy: false, directDns: null, CancellationToken.None);
Check.True(NetworkComparison.SameConfiguration(before, actual), "Switch should return the unchanged verified configuration");
Check.Equal(0, harness.Backend.Writes.Count, "An already matching route and DNS must not be rewritten");
Check.Equal(1, harness.Probe.VerifyCount, "No-op switching should still verify the actual egress");
Check.Equal(2, harness.Backend.CaptureCount, "Switch should capture once for preflight and once for readback");
Check.Equal("Verified", harness.Store.Read<SwitchJournal>(SwitchService.JournalFile)?.Stage, "A verified no-op should leave the completed journal for the controller");
}
public static async Task SwitchWritesOnlyChangedDnsAsync()
{
var route = Fixtures.Baseline().Routes[0] with { Metric = 0 };
var before = Fixtures.Baseline() with { Routes = [route] };
var directDns = new DnsConfiguration(Automatic: false, Servers: ["192.168.6.53"], ProfileServers: []);
using var harness = new SwitchHarness(before);
var actual = await harness.Service.SwitchAsync(
Fixtures.Settings(), before, proxy: false, directDns, CancellationToken.None);
Check.SequenceEqual([new BackendWrite("dns", "192.168.6.53")], harness.Backend.Writes, "Only the changed DNS component should be written");
Check.True(NetworkComparison.SameDns(directDns, actual.Dns), "Readback should expose the requested direct DNS");
Check.Equal(1, harness.Probe.VerifyCount, "The changed configuration should be verified");
}
public static async Task SwitchWritesOnlyChangedRoutesAsync()
{
var settings = Fixtures.Settings();
using var harness = new SwitchHarness(Fixtures.Baseline());
var proxy = harness.Service.Plan(settings, Fixtures.Baseline(), proxy: true, directDns: null);
var before = proxy with { Dns = DnsConfiguration.Auto, EffectiveDns = ["192.168.6.1"] };
harness.Backend.Current = before;
var actual = await harness.Service.SwitchAsync(
settings, before, proxy: false, directDns: null, CancellationToken.None);
Check.SequenceEqual([new BackendWrite("route", "192.168.6.1")], harness.Backend.Writes, "Only the changed route component should be written");
Check.True(NetworkComparison.SameDns(DnsConfiguration.Auto, actual.Dns), "Unchanged direct DNS must not be rewritten");
Check.Equal("192.168.6.1", actual.Gateway, "Readback should expose the direct gateway");
Check.Equal(1, harness.Probe.VerifyCount, "The changed route should be verified");
}
public static async Task RollbackSameConfigurationSkipsWritesAsync()
{
var route = Fixtures.Baseline().Routes[0] with { Metric = 0 };
var before = Fixtures.Baseline() with { Routes = [route] };
using var harness = new SwitchHarness(before);
var intended = harness.Service.Plan(Fixtures.Settings(), before, proxy: true, directDns: null);
var journal = new SwitchJournal(before, intended, "ApplyingDns", DateTimeOffset.UtcNow);
var actual = await harness.Service.RollbackAsync(journal, CancellationToken.None);
Check.True(NetworkComparison.SameConfiguration(before, actual), "Rollback of an unchanged state should return the baseline");
Check.Equal(0, harness.Backend.Writes.Count, "Rollback must skip both writes when routes and DNS already match");
Check.Equal(2, harness.Backend.CaptureCount, "Rollback should capture before deciding and after verification");
Check.Equal<SwitchJournal?>(null, harness.Store.Read<SwitchJournal>(SwitchService.JournalFile), "Verified no-op rollback should clear its journal");
}
public static async Task BaselineRestoreSameConfigurationSkipsWritesAsync()
{
var route = Fixtures.Baseline().Routes[0] with { Metric = 0 };
var baseline = Fixtures.Baseline() with { Routes = [route] };
using var harness = new SwitchHarness(baseline);
var actual = await harness.Service.RestoreBaselineAsync(baseline, baseline, CancellationToken.None);
Check.True(NetworkComparison.SameConfiguration(baseline, actual), "Restoring an unchanged baseline should return the verified snapshot");
Check.Equal(0, harness.Backend.Writes.Count, "Baseline restore must skip both writes when nothing changed");
Check.Equal(2, harness.Backend.CaptureCount, "Baseline restore should capture for preflight and readback");
Check.Equal("Verified", harness.Store.Read<SwitchJournal>(SwitchService.JournalFile)?.Stage, "No-op restore should leave the completed journal for the controller");
}
}
+244
View File
@@ -0,0 +1,244 @@
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>(T expected, T actual, string message)
{
if (!EqualityComparer<T>.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<T>(IEnumerable<T> expected, IEnumerable<T> 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<TException>(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<TException> ThrowsAsync<TException>(Func<Task> 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<Exception> ThrowsAnyAsync(Func<Task> 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<BackendWrite> Writes { get; } = [];
public int CaptureCount { get; private set; }
public Func<Guid, CancellationToken, Task<NetworkSnapshot>>? CaptureBehavior { get; set; }
public Func<NetworkSnapshot, Task>? RouteWriteBehavior { get; set; }
public Func<DnsConfiguration, Task>? DnsWriteBehavior { get; set; }
public Task<IReadOnlyList<AdapterInfo>> ListAdaptersAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult<IReadOnlyList<AdapterInfo>>([Current.Adapter]);
}
public async Task<NetworkSnapshot> 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 ?? "<empty>"));
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<AppSettings, NetworkSnapshot, CancellationToken, Task<HealthReport>>? 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<HealthReport> 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<bool> 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<string> 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);
}