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( () => 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(null, harness.Store.Read(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( () => 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(null, harness.Store.Read(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( () => 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(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( () => 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(null, harness.Store.Read(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( () => 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(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( () => 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( () => 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( () => 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(null, harness.Store.Read(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( () => 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( () => 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( () => 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( () => 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( () => 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(null, harness.Store.Read(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( () => 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( () => 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(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(null, harness.Store.Read(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(SwitchService.JournalFile)?.Stage, "No-op restore should leave the completed journal for the controller"); } }