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

345 lines
21 KiB
C#

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