Integrate Xray 26.9.9 with the sing-box system tunnel and verify Windows routes during health checks. Improve endpoint selection, network recovery, subscription refresh deadlines, diagnostics, packaging, and regression coverage.
173 lines
11 KiB
C#
173 lines
11 KiB
C#
using QVPN.Core;
|
|
|
|
internal static class NodeSelectionTests
|
|
{
|
|
private static readonly VpnNode First = Node("first");
|
|
private static readonly VpnNode Second = Node("second");
|
|
private static readonly VpnNode Third = Node("third");
|
|
private static readonly VpnNode[] Nodes = [First, Second, Third];
|
|
|
|
public static async Task RunAsync(Func<string, Action, Task> test)
|
|
{
|
|
await test("Selected Zurich survives server and credential rotation in a reordered subscription", () =>
|
|
{
|
|
var zurich = First with { Id = "zurich-old", Name = "Цюрих", Host = "203.0.113.10" };
|
|
var replacement = zurich with
|
|
{
|
|
Id = "zurich-new", Name = " ЦЮРИХ\t", Host = "203.0.113.20", Port = 8443,
|
|
Uuid = "00000000-0000-4000-8000-000000000003", PublicKey = "rotated-fixture-key", ShortId = "1234"
|
|
};
|
|
var amsterdam = Second with { Name = "Амстердам", Host = "203.0.113.30" };
|
|
VpnNode[] refreshed = [amsterdam, replacement];
|
|
var selected = NodeSelection.FindReplacement(zurich, refreshed);
|
|
Check(selected == replacement);
|
|
Check(NodeSelection.Choose(refreshed, selected!.Id, true, selected.Id, new HashSet<string>(),
|
|
new Dictionary<string, int?> { [amsterdam.Id] = 1 }) == replacement);
|
|
// Preserving the location must still restart the engine when its transport changed.
|
|
Check(!NodeSelection.ConnectionEquals(zurich, replacement));
|
|
});
|
|
await test("Replacement prefers exact ID before matching a location name", () =>
|
|
{
|
|
var exact = First with { Name = "Updated presentation" };
|
|
var sameName = Second with { Name = First.Name };
|
|
Check(NodeSelection.FindReplacement(First, [sameName, exact]) == exact);
|
|
});
|
|
await test("Saved transport survives ID migration among identical location names and endpoints", () =>
|
|
{
|
|
var unchanged = First with { Id = "new-id" };
|
|
var alternateFlow = unchanged with { Id = "other-flow", Flow = "xtls-rprx-vision-udp443" };
|
|
var alternateFingerprint = unchanged with { Id = "other-fingerprint", Fingerprint = "firefox" };
|
|
Check(NodeSelection.FindReplacement(First, [alternateFlow, alternateFingerprint, unchanged]) == unchanged);
|
|
});
|
|
await test("Unchanged subscription refresh retains failures and tries a fresh server", () =>
|
|
{
|
|
var rejected = First with { Id = "new-id", Name = "Renamed" };
|
|
var fresh = Second with { Host = "203.0.113.2" };
|
|
var failed = NodeSelection.RemapFailedConnections([First, fresh], [rejected, fresh], new HashSet<string> { First.Id });
|
|
Check(failed.SetEquals([rejected.Id]));
|
|
Check(NodeSelection.Choose([rejected, fresh], rejected.Id, true, rejected.Id, failed, new Dictionary<string, int?>()) == fresh);
|
|
});
|
|
await test("Changed credentials or transport allow retry after subscription refresh", () =>
|
|
{
|
|
var rotated = First with { Id = "rotated", ShortId = "1234" };
|
|
var flowChanged = First with { Id = "flow", Flow = "xtls-rprx-vision-udp443" };
|
|
var fingerprintChanged = First with { Id = "fingerprint", Fingerprint = "firefox" };
|
|
var failed = NodeSelection.RemapFailedConnections([First], [rotated, flowChanged, fingerprintChanged], new HashSet<string> { First.Id });
|
|
Check(failed.Count == 0);
|
|
});
|
|
await test("Replacement normalizes Unicode and whitespace in a unique location name", () =>
|
|
{
|
|
var old = First with { Name = " Zurich\u00a0\u00a0Cafe\u0301 " };
|
|
var replacement = Second with { Name = "zurich CAFÉ", Host = "203.0.113.20" };
|
|
Check(NodeSelection.FindReplacement(old, [Third, replacement]) == replacement);
|
|
});
|
|
await test("A unique unchanged endpoint disambiguates duplicated location names", () =>
|
|
{
|
|
var old = First with { Name = "Цюрих", Host = "203.0.113.10" };
|
|
var replacement = Second with { Name = old.Name, Host = old.Host, Port = old.Port };
|
|
var duplicateName = Third with { Name = old.Name, Host = "203.0.113.20" };
|
|
Check(NodeSelection.FindReplacement(old, [duplicateName, replacement]) == replacement);
|
|
Check(NodeSelection.FindReplacement(old, [replacement with { Name = "Renamed location" }]) is not null);
|
|
});
|
|
await test("Unmatched or ambiguous rotated locations have no guessed replacement", () =>
|
|
{
|
|
var old = First with { Name = "Цюрих", Host = "203.0.113.10" };
|
|
var one = Second with { Name = old.Name, Host = "203.0.113.20" };
|
|
var two = Third with { Name = " ЦЮРИХ ", Host = "203.0.113.30" };
|
|
Check(NodeSelection.FindReplacement(old, [one, two]) is null);
|
|
Check(NodeSelection.FindReplacement(old, [Second with { Name = "Амстердам", Host = "203.0.113.40" }]) is null);
|
|
Check(NodeSelection.FindReplacement(old, [one with { Host = old.Host }, two with { Host = old.Host }]) is null);
|
|
Check(NodeSelection.FindReplacement(old, []) is null);
|
|
});
|
|
await test("Manual location remains selected after failure and a negative TCP probe", () =>
|
|
{
|
|
var failed = new HashSet<string> { Second.Id };
|
|
var latencies = new Dictionary<string, int?> { [First.Id] = 10, [Second.Id] = null };
|
|
Check(NodeSelection.Choose(Nodes, Second.Id, false, First.Id, failed, latencies) == Second);
|
|
});
|
|
await test("Disabling failover preserves the working server when no location is selected", () =>
|
|
{
|
|
Check(NodeSelection.Choose(Nodes, "missing", false, Second.Id,
|
|
new HashSet<string> { First.Id }, new Dictionary<string, int?> { [Third.Id] = 1 }) == Second);
|
|
Check(NodeSelection.Choose(Nodes, "missing", false, "removed", new HashSet<string>(), new Dictionary<string, int?>()) == First);
|
|
});
|
|
await test("Automatic switching tries every fresh location before any failed location", () =>
|
|
{
|
|
var failed = new HashSet<string> { First.Id, Second.Id };
|
|
var latencies = new Dictionary<string, int?> { [First.Id] = 1, [Second.Id] = 2, [Third.Id] = 300 };
|
|
Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) == Third);
|
|
failed.Add(Third.Id);
|
|
Check(NodeSelection.Choose(Nodes, First.Id, true, Second.Id, failed, latencies) is null);
|
|
failed.Clear();
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, failed, latencies) == First);
|
|
});
|
|
await test("Explicit location is attempted despite a negative TCP measurement", () =>
|
|
{
|
|
Check(NodeSelection.Choose(Nodes, Second.Id, true, Third.Id, new HashSet<string>(),
|
|
new Dictionary<string, int?> { [First.Id] = 1, [Second.Id] = null, [Third.Id] = 2 }) == Second);
|
|
});
|
|
await test("Last working location remains sticky across subscription order and latency changes", () =>
|
|
{
|
|
var latencies = new Dictionary<string, int?> { [First.Id] = 1, [Second.Id] = 80, [Third.Id] = 2 };
|
|
Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet<string>(), latencies) == Second);
|
|
Check(NodeSelection.Choose([Third, First, Second], "", true, Second.Id, new HashSet<string>(), latencies) == Second);
|
|
Check(NodeSelection.Choose(Nodes, "", true, Second.Id, new HashSet<string> { Second.Id }, latencies) == First);
|
|
});
|
|
await test("Fresh reachable locations rank by latency ahead of missing and negative measurements", () =>
|
|
{
|
|
var latencies = new Dictionary<string, int?> { [First.Id] = null, [Second.Id] = 3000 };
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string>(), latencies) == Second);
|
|
latencies[Third.Id] = 20;
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string>(), latencies) == Third);
|
|
});
|
|
await test("TCP timeout does not discard a verified server; failed VPN checks do", () =>
|
|
{
|
|
Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet<string>(),
|
|
new Dictionary<string, int?> { [First.Id] = null, [Third.Id] = null }) == First);
|
|
Check(NodeSelection.Choose(Nodes, "", true, First.Id, new HashSet<string> { First.Id },
|
|
new Dictionary<string, int?> { [First.Id] = null, [Third.Id] = null }) == Second);
|
|
});
|
|
await test("All negative TCP measurements still allow an untried VPN attempt", () =>
|
|
{
|
|
var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)null);
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string>(), latencies) == First);
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string> { First.Id }, latencies) == Second);
|
|
Check(NodeSelection.Choose(Nodes, "", true, Third.Id, new HashSet<string>(), latencies) == Third);
|
|
});
|
|
await test("Removed last working location and latency ties use stable fresh candidates", () =>
|
|
{
|
|
var latencies = Nodes.ToDictionary(n => n.Id, _ => (int?)25);
|
|
Check(NodeSelection.Choose(Nodes, "removed", true, "removed", new HashSet<string>(), latencies) == First);
|
|
Check(NodeSelection.Choose(Nodes, "", true, null, new HashSet<string> { First.Id }, latencies) == Second);
|
|
Check(NodeSelection.Choose([], "", true, null, new HashSet<string>(), latencies) is null);
|
|
});
|
|
await test("Subscription presentation changes do not alter VPN connection identity", () =>
|
|
{
|
|
Check(NodeSelection.ConnectionEquals(First, First with { Name = "Renamed location", Id = "new presentation id" }));
|
|
Check(NodeSelection.ConnectionEquals(null, null));
|
|
Check(!NodeSelection.ConnectionEquals(First, null));
|
|
Check(!NodeSelection.ConnectionEquals(null, First));
|
|
});
|
|
await test("Every changed connection field requires a new VPN connection", () =>
|
|
{
|
|
VpnNode[] changed =
|
|
[
|
|
First with { Host = "203.0.113.2" }, First with { Port = 8443 },
|
|
First with { Uuid = "00000000-0000-4000-8000-000000000002" },
|
|
First with { ServerName = "other.invalid" }, First with { PublicKey = "other-test-key" },
|
|
First with { ShortId = "abce" }, First with { Fingerprint = "firefox" },
|
|
First with { Flow = "" }
|
|
];
|
|
foreach (var node in changed) Check(!NodeSelection.ConnectionEquals(First, node));
|
|
});
|
|
}
|
|
|
|
private static VpnNode Node(string id) => new(id, "Test " + id, "203.0.113.1", 443,
|
|
"00000000-0000-4000-8000-000000000001", "test.invalid", "fixture-key", "abcd", "chrome", "xtls-rprx-vision");
|
|
|
|
private static void Check(bool condition)
|
|
{
|
|
if (!condition) throw new InvalidOperationException("Node selection regression.");
|
|
}
|
|
}
|