245 lines
9.7 KiB
C#
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);
|
|
}
|