Files
QVPN/tests/QVPN.Tests/NodeSelectionTests.cs
Курнат Андрей aae6dd28dd Release QVPN 1.0.5 with Xray and resilient tunnel recovery
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.
2026-10-30 06:15:08 +03:00

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