Files
Gateway-keepaliveForDotnet/tests/GatewayKeepalive.Tests/GatewayControllerTests.cs
T
chuzhongzai 2d85527656 实现 .NET 10 托盘版网关保活
包含分层探测、可补偿的 IPv4 路由/DNS 切换、自动策略、托盘与分组设置界面、只读诊断工具、离线测试和发布脚本。
2026-10-23 23:35:20 +08:00

355 lines
21 KiB
C#

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();
}
}