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

245 lines
9.7 KiB
C#

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