实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user