345 lines
21 KiB
C#
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");
|
|
}
|
|
}
|