fix(webapi): preserve Unix environment names and isolate test fixtures (#10455)

* fix(webapi): preserve Unix environment names and isolate local test fixtures

* test(webapi): isolate launcher fixtures from host process IDs

* test(webapi): allow auth fixture scheduling headroom
This commit is contained in:
Miheichev Aleksandr Sergeevich
2026-11-02 01:20:34 +00:00
committed by GitHub
parent ebabf79017
commit 743eb323c2
6 changed files with 182 additions and 35 deletions
@@ -109,7 +109,8 @@ public static class LauncherEnvironment
public static Dictionary<string, string?> ReadCurrentEnvironment() =>
Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>()
.ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
.ToDictionary(entry => (string)entry.Key, entry => entry.Value?.ToString(),
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
private static bool HasValue(IReadOnlyDictionary<string, string?> environment, string key) =>
environment.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value);
@@ -178,7 +178,8 @@ public class CoreProcessTrackerTests
private static string CopySleepExecutable(string directory)
{
var executable = Path.Combine(directory, "sleep-core-test");
// Multicall implementations dispatch by basename, so preserve the sleep utility name.
var executable = Path.Combine(directory, "sleep");
File.Copy("/bin/sleep", executable);
return executable;
}
@@ -0,0 +1,100 @@
using System.Collections;
using v2rayN.WebAPI.Launcher;
namespace v2rayN.WebAPI.Tests;
[NotInParallel]
public class LauncherEnvironmentTests
{
[Test]
[Arguments("same-value", "same-value")]
[Arguments("upper-value", "lower-value")]
public async Task CurrentEnvironmentPreservesPlatformVariableNameSemantics(string upperValue, string lowerValue)
{
var upperName = $"V2RAYN_ENVIRONMENT_TEST_{Guid.NewGuid():N}".ToUpperInvariant();
var lowerName = upperName.ToLowerInvariant();
using var environmentScope = new TemporaryEnvironment((upperName, upperValue), (lowerName, lowerValue));
var environment = LauncherEnvironment.ReadCurrentEnvironment();
await environment[upperName].Should().BeEqualTo(OperatingSystem.IsWindows() ? lowerValue : upperValue);
await environment[lowerName].Should().BeEqualTo(lowerValue);
await environment.Keys.Count(key => string.Equals(key, upperName, StringComparison.OrdinalIgnoreCase))
.Should().BeEqualTo(OperatingSystem.IsWindows() ? 1 : 2);
}
[Test]
[Arguments("V2RAYN_WEB_API_KEY")]
[Arguments("LC_ALL")]
public async Task CurrentEnvironmentRequiresCanonicalSettingNamesOnUnix(string canonicalName)
{
var lowerName = canonicalName.ToLowerInvariant();
using var environmentScope = new TemporaryEnvironment((canonicalName, null), (lowerName, "wrong-case-value"));
var environment = LauncherEnvironment.ReadCurrentEnvironment();
await environment.GetValueOrDefault(canonicalName).Should()
.BeEqualTo(OperatingSystem.IsWindows() ? "wrong-case-value" : null);
await environment[lowerName].Should().BeEqualTo("wrong-case-value");
}
[Test]
public async Task CurrentEnvironmentKeepsCanonicalManagementKeyDistinctOnUnix()
{
using var environmentScope = new TemporaryEnvironment(
("V2RAYN_WEB_API_KEY", "canonical-management-key-123456"),
("v2rayn_web_api_key", "other-management-key-123456"));
var environment = LauncherEnvironment.ReadCurrentEnvironment();
await environment.GetValueOrDefault("V2RAYN_WEB_API_KEY").Should()
.BeEqualTo(OperatingSystem.IsWindows() ? "other-management-key-123456" : "canonical-management-key-123456");
await environment["v2rayn_web_api_key"].Should().BeEqualTo("other-management-key-123456");
}
[Test]
public async Task CurrentEnvironmentUsesCanonicalInteractiveLaunchMarkerOnUnix()
{
using var environmentScope = new TemporaryEnvironment(
(LauncherEnvironment.InteractiveLaunchEnvironmentVariable, null),
(LauncherEnvironment.InteractiveLaunchEnvironmentVariable.ToLowerInvariant(), "1"));
var environment = LauncherEnvironment.ReadCurrentEnvironment();
await LauncherEnvironment.IsInteractiveLaunchMarker(environment).Should().BeEqualTo(OperatingSystem.IsWindows());
}
private sealed class TemporaryEnvironment : IDisposable
{
private readonly string[] _names;
private readonly (string Name, string? Value)[] _originalValues;
public TemporaryEnvironment(params (string Name, string? Value)[] values)
{
_names = values.Select(value => value.Name).ToArray();
var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
_originalValues = Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Where(entry => _names.Contains((string)entry.Key, comparer))
.Select(entry => ((string)entry.Key, entry.Value?.ToString()))
.ToArray();
foreach (var (name, value) in values)
{
Environment.SetEnvironmentVariable(name, value);
}
}
public void Dispose()
{
foreach (var name in _names)
{
Environment.SetEnvironmentVariable(name, null);
}
foreach (var (name, value) in _originalValues)
{
Environment.SetEnvironmentVariable(name, value);
}
}
}
}
@@ -495,7 +495,8 @@ public class WebAuthEndpointTests
var client = new HttpClient(new SocketsHttpHandler { UseProxy = false })
{
BaseAddress = new Uri(addresses.Single()),
Timeout = TimeSpan.FromSeconds(5),
// Allow headroom for real authentication work under parallel test load.
Timeout = TimeSpan.FromSeconds(20),
};
return new ApiHarness(app, client, app.Services.GetRequiredService<EventHub>());
}
@@ -34,9 +34,7 @@ public class WebAuthRateLimitTests
await app.StartAsync();
try
{
var server = app.Services.GetRequiredService<IServer>();
var address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
using var client = new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) };
using var client = CreateClient(app);
var statuses = new List<HttpStatusCode>();
int? retryAfterSeconds = null;
@@ -118,6 +116,38 @@ public class WebAuthRateLimitTests
}
}
[Test]
[NotInParallel]
public async Task LocalRateLimitRequestsDoNotUseTheDefaultProxy()
{
var proxyBuilder = WebApplication.CreateBuilder();
proxyBuilder.WebHost.ConfigureKestrel(options => options.Listen(IPAddress.Loopback, 0));
await using var proxy = proxyBuilder.Build();
proxy.Run(context =>
{
context.Response.StatusCode = StatusCodes.Status502BadGateway;
return Task.CompletedTask;
});
await proxy.StartAsync();
await using var app = CreatePartitionTestApp();
await app.StartAsync();
var proxyAddress = proxy.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
var previousProxy = HttpClient.DefaultProxy;
try
{
HttpClient.DefaultProxy = new FixedProxy(new Uri(proxyAddress));
using var client = CreateClient(app);
await (await GetLimitedStatusAsync(client, "/setup-limited", "192.0.2.1", 0))
.Should().BeEqualTo(HttpStatusCode.OK);
}
finally
{
HttpClient.DefaultProxy = previousProxy;
await app.StopAsync();
await proxy.StopAsync();
}
}
private static WebApplication CreatePartitionTestApp()
{
var builder = WebApplication.CreateBuilder();
@@ -149,7 +179,11 @@ public class WebAuthRateLimitTests
{
var server = app.Services.GetRequiredService<IServer>();
var address = server.Features.Get<IServerAddressesFeature>()!.Addresses.Single();
return new HttpClient { BaseAddress = new Uri(address), Timeout = TimeSpan.FromSeconds(20) };
return new HttpClient(new SocketsHttpHandler { UseProxy = false })
{
BaseAddress = new Uri(address),
Timeout = TimeSpan.FromSeconds(20),
};
}
private static async Task<HttpStatusCode> GetLimitedStatusAsync(HttpClient client, string endpoint, string remoteIp, int requestIndex)
@@ -163,6 +197,12 @@ public class WebAuthRateLimitTests
return response.StatusCode;
}
private sealed class FixedProxy(Uri address) : IWebProxy
{
public ICredentials? Credentials { get; set; }
public Uri GetProxy(Uri destination) => address;
public bool IsBypassed(Uri host) => false;
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
+32 -28
View File
@@ -4,6 +4,10 @@ namespace v2rayN.WebAPI.Tests;
public class WebLauncherTests
{
// Keep fixture PIDs outside Linux's assignable range so host services cannot affect stop tests.
private const int FakeOwnerProcessId = int.MaxValue - 1;
private const int OtherProcessId = int.MaxValue;
[Test]
public async Task InstanceLockOwnerCanBeReadWhileAnotherProcessHoldsTheLock()
{
@@ -228,13 +232,13 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
await File.WriteAllTextAsync(lockPath, "123\n");
await File.WriteAllTextAsync(lockPath, $"{FakeOwnerProcessId}\n");
var signals = new FakeSignalSender();
var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals);
var stopper = new WebStopper(new FakeHealthProbe(true, FakeOwnerProcessId), signals);
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.NotRunning).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.NotRunning);
await (signals.ProcessIds.Count == 0).Should().BeTrue();
}
@@ -245,11 +249,11 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
using (heldLock)
{
var signals = new FakeSignalSender();
var health = new FakeHealthProbe(true, 123);
var health = new FakeHealthProbe(true, FakeOwnerProcessId);
signals.OnSignal = processId =>
{
_ = ReleaseLockAfterDelayAsync(heldLock, health);
@@ -262,8 +266,8 @@ public class WebLauncherTests
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.Stopped).Should().BeTrue();
await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.Stopped);
await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue();
await (health.ProbeCount >= 2).Should().BeTrue();
}
}
@@ -282,18 +286,18 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
using (heldLock)
{
var health = new FakeHealthProbe(true, 123, [Environment.ProcessId]);
var health = new FakeHealthProbe(true, FakeOwnerProcessId, [Environment.ProcessId]);
var signals = new FakeSignalSender();
signals.OnSignal = processId => _ = ReleaseLockAfterDelayAsync(heldLock, health);
var stopper = new WebStopper(health, signals, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(10));
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.CoreProcessStillRunning).Should().BeTrue();
await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.CoreProcessStillRunning);
await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue();
}
}
@@ -304,13 +308,13 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
var signals = new FakeSignalSender();
var stopper = new WebStopper(new FakeHealthProbe(true, 456), signals);
var stopper = new WebStopper(new FakeHealthProbe(true, OtherProcessId), signals);
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.IdentityUnverified).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.IdentityUnverified);
await (signals.ProcessIds.Count == 0).Should().BeTrue();
}
@@ -321,13 +325,13 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
var signals = new FakeSignalSender();
var stopper = new WebStopper(new FakeHealthProbe(false, 123), signals);
var stopper = new WebStopper(new FakeHealthProbe(false, FakeOwnerProcessId), signals);
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.IdentityUnverified).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.IdentityUnverified);
await (signals.ProcessIds.Count == 0).Should().BeTrue();
}
@@ -338,18 +342,18 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
var signals = new FakeSignalSender();
var stopper = new WebStopper(new FakeHealthProbe(true, 123), signals);
var stopper = new WebStopper(new FakeHealthProbe(true, FakeOwnerProcessId), signals);
var result = await stopper.StopAsync(lockPath, healthUri: null);
await (result == WebStopResult.ProbeUnavailable).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.ProbeUnavailable);
await (signals.ProcessIds.Count == 0).Should().BeTrue();
var notRunning = await stopper.StopAsync(
Path.Combine(directory.Path, "missing-instance.lock"), healthUri: null);
await (notRunning == WebStopResult.NotRunning).Should().BeTrue();
await notRunning.Should().BeEqualTo(WebStopResult.NotRunning);
}
[Test]
@@ -359,18 +363,18 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
var signals = new FakeSignalSender();
var stopper = new WebStopper(
new FakeHealthProbe(true, 123),
new FakeHealthProbe(true, FakeOwnerProcessId),
signals,
stopTimeout: TimeSpan.FromMilliseconds(60),
pollInterval: TimeSpan.FromMilliseconds(5));
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.TimedOut).Should().BeTrue();
await signals.ProcessIds.SequenceEqual([123]).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.TimedOut);
await signals.ProcessIds.SequenceEqual([FakeOwnerProcessId]).Should().BeTrue();
await WebInstanceLock.IsHeld(lockPath).Should().BeTrue();
}
@@ -381,8 +385,8 @@ public class WebLauncherTests
using var directory = new TemporaryDirectory();
var lockPath = Path.Combine(directory.Path, "instance.lock");
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, 123);
var health = new FakeHealthProbe(true, 123);
using var heldLock = await AcquireLockWithOwnerAsync(lockPath, FakeOwnerProcessId);
var health = new FakeHealthProbe(true, FakeOwnerProcessId);
health.SetShutdownStage("Core stop");
var stopper = new WebStopper(
health,
@@ -392,7 +396,7 @@ public class WebLauncherTests
var result = await stopper.StopAsync(lockPath, new Uri("http://127.0.0.1:5080/api/health"));
await (result == WebStopResult.TimedOut).Should().BeTrue();
await result.Should().BeEqualTo(WebStopResult.TimedOut);
await (stopper.LastObservedShutdownStage == "Core stop").Should().BeTrue();
await LauncherMessages.StopMessage(result, LauncherLocale.English, stopper.LastObservedShutdownStage)
.Contains("Core stop", StringComparison.Ordinal).Should().BeTrue();