Files
QVPN/tests/QVPN.Tests/SubscriptionDownloadTests.cs
T
Курнат Андрей 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

128 lines
6.9 KiB
C#

using System.Diagnostics;
using System.Net;
using QVPN.Core;
public static class SubscriptionDownloadTests
{
private static readonly string ProviderUrl = "https://test.withblancvpn.online/s/" + new string('a', 32);
public static async Task HeadersDoNotEndBodyDeadlineAsync()
{
using var caller = new CancellationTokenSource();
using var body = new StallingStream();
bool headersReturned = false;
using var handler = new StubHandler(async (_, token) =>
{
await Task.Delay(30, token);
headersReturned = true;
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) };
});
using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
var watch = Stopwatch.StartNew();
try
{
try
{
await Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromMilliseconds(150), caller.Token)
.WaitAsync(TimeSpan.FromSeconds(1));
throw new InvalidOperationException("A stalled subscription body unexpectedly completed.");
}
catch (OperationCanceledException)
{
Check(!caller.IsCancellationRequested, "The download required caller cancellation instead of its own body deadline.");
}
Check(headersReturned && body.ReadStarted.Task.IsCompleted, "The regression did not reach the streamed body after receiving headers.");
Check(body.ReadCancelled && body.Disposed, "The timed-out response did not cancel its body read and dispose the transport.");
Check(watch.Elapsed < TimeSpan.FromSeconds(1), "The complete request exceeded its body deadline.");
}
finally { caller.Cancel(); }
}
public static async Task CallerCancellationWinsBodyDeadlineAsync()
{
using var caller = new CancellationTokenSource();
using var body = new StallingStream();
using var handler = new StubHandler((_, _) => Task.FromResult(
new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(body) }));
using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
var running = Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromSeconds(5), caller.Token);
await body.ReadStarted.Task.WaitAsync(TimeSpan.FromSeconds(1));
caller.Cancel();
try
{
await running.WaitAsync(TimeSpan.FromSeconds(1));
throw new InvalidOperationException("Caller cancellation was converted into a successful subscription.");
}
catch (OperationCanceledException error)
{
Check(error.CancellationToken == caller.Token, "The shorter caller deadline lost its original cancellation token.");
}
Check(body.ReadCancelled && body.Disposed, "Caller cancellation did not stop and dispose the streamed response.");
}
public static async Task CompletedBodyPreservesValidationAsync()
{
int requests = 0;
using var handler = new StubHandler((request, _) =>
{
Interlocked.Increment(ref requests);
Check(request.RequestUri!.Scheme == Uri.UriSchemeHttps && request.RequestUri.Host == "test.withblancvpn.online",
"The bounded download changed the validated HTTPS request.");
string uuid = new Guid(Enumerable.Range(1, 16).Select(i => (byte)i).ToArray()).ToString();
string key = Convert.ToBase64String(Enumerable.Range(1, 32).Select(i => (byte)i).ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent($"vless://{uuid}@8.8.8.8:443?security=reality&type=tcp&pbk={key}&sid=abcd&sni=example.com&fp=chrome&flow=xtls-rprx-vision-udp443#Synthetic")
};
response.Headers.TryAddWithoutValidation("subscription-userinfo", "upload=0; download=1");
return Task.FromResult(response);
});
using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
var result = await Subscription.FetchWithClientAsync(ProviderUrl, http, TimeSpan.FromSeconds(1), CancellationToken.None);
Check(result.Nodes.Count == 1 && result.Nodes[0].Flow == "xtls-rprx-vision-udp443" && result.Info == "upload=0; download=1",
"A complete bounded response lost nodes, supported transport fields, or subscription metadata.");
try
{
await Subscription.FetchWithClientAsync("https://example.org/s/" + new string('a', 32), http, TimeSpan.FromSeconds(1), CancellationToken.None);
throw new InvalidOperationException("Injected transport bypassed provider validation.");
}
catch (ArgumentException) { }
Check(requests == 1, "An untrusted provider URL reached the injected transport.");
}
private static void Check(bool condition, string message)
{
if (!condition) throw new InvalidOperationException(message);
}
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> send) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token);
}
private sealed class StallingStream : Stream
{
public TaskCompletionSource ReadStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool ReadCancelled { get; private set; }
public bool Disposed { get; private set; }
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken token = default)
{
ReadStarted.TrySetResult();
try { await Task.Delay(Timeout.InfiniteTimeSpan, token); }
catch (OperationCanceledException) { ReadCancelled = true; throw; }
return 0;
}
protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); }
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
}