This commit is contained in:
2026-07-17 02:19:05 +03:00
parent 4cd9f928ff
commit dbd5715578
42 changed files with 6000 additions and 866 deletions
+849
View File
@@ -0,0 +1,849 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using Esiur.AspNetCore;
using Esiur.Core;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using Esiur.Stores;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace Esiur.Tests.Unit;
public sealed class AspNetCoreIntegrationTests
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task AddEsiur_ValidatesConfigurationWhenHostStarts()
{
await using var application = BuildApplication(builder =>
builder.AddMemoryStore("sys"));
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"Authentication is required",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public async Task AddEsiur_RejectsAnonymousOnlyRequiredEncryption()
{
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AllowAnonymous()
.UseEncryption(new AesEncryptionProvider())
.RequireEncryption());
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"Encrypted EP sessions require authentication",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void ProviderFacade_ExposesInstancesTypesAndFactoriesWithoutProtocolAliases()
{
var authenticationMethods = typeof(EsiurBuilder).GetMethods()
.Where(method => method.Name == nameof(EsiurBuilder.UseAuthentication))
.ToArray();
var encryptionMethods = typeof(EsiurBuilder).GetMethods()
.Where(method => method.Name == nameof(EsiurBuilder.UseEncryption))
.ToArray();
Assert.Equal(3, authenticationMethods.Length);
Assert.Equal(3, encryptionMethods.Length);
Assert.All(
authenticationMethods.Concat(encryptionMethods),
method => Assert.DoesNotContain(
method.GetParameters(),
parameter => parameter.ParameterType == typeof(string)));
var services = new ServiceCollection();
var esiur = services.AddEsiur();
var authenticationProvider = new PasswordAuthenticationProvider();
var encryptionProvider = new AesEncryptionProvider();
esiur
.UseAuthentication(authenticationProvider)
.UseAuthentication<PasswordAuthenticationProvider>()
.UseAuthentication(_ => authenticationProvider)
.UseEncryption(encryptionProvider)
.UseEncryption<AesEncryptionProvider>()
.UseEncryption(_ => encryptionProvider);
}
[Fact]
public async Task ProviderFacade_RegistersAndAllowsOnlyProviderDefaultNames()
{
var authenticationProvider = new PasswordAuthenticationProvider();
var encryptionProvider = new AesEncryptionProvider();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.UseAuthentication(authenticationProvider)
.UseEncryption(encryptionProvider)
.RequireEncryption());
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var warehouse = application.Services.GetRequiredService<Warehouse>();
var server = application.Services.GetRequiredService<EpServer>();
Assert.Same(
authenticationProvider,
warehouse.TryGetAuthenticationProvider(authenticationProvider.DefaultName));
Assert.Same(
encryptionProvider,
warehouse.TryGetEncryptionProvider(encryptionProvider.DefaultName));
Assert.Equal(
new[] { authenticationProvider.DefaultName },
server.AllowedAuthenticationProviders);
Assert.Equal(
new[] { encryptionProvider.DefaultName },
server.AllowedEncryptionProviders);
}
[Theory]
[InlineData(false, "Warehouse.Configuration.Connections is required")]
[InlineData(true, "MaximumConnections cannot be negative")]
public async Task AddEsiur_RejectsNullOrNegativeConnectionConfiguration(
bool useNegativeLimit,
string expectedFailure)
{
await using var application = BuildApplication(
builder => builder.AddMemoryStore("sys").AllowAnonymous(),
configureWarehouse: configuration =>
{
if (useNegativeLimit)
configuration.Connections.MaximumConnections = -1;
else
configuration.Connections = null!;
});
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(expectedFailure, exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task AddEsiur_RejectsPerConnectionSendLimitAboveHostBudget()
{
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AllowAnonymous()
.LimitPendingWebSocketSendBytes(2)
.LimitTotalPendingWebSocketSendBytes(1));
var exception = await Assert.ThrowsAsync<OptionsValidationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"cannot exceed the host-wide send limit",
exception.Message,
StringComparison.Ordinal);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task AddEsiur_UsesCodeOnlyExceptionsUnlessMessagesAreExplicitlyIncluded(
bool includeMessages)
{
await using var application = BuildApplication(builder =>
{
builder.AddMemoryStore("sys").AllowAnonymous();
if (includeMessages)
builder.IncludeExceptionMessages();
});
var options = application.Services
.GetRequiredService<IOptions<EsiurOptions>>()
.Value;
var expected = ExceptionLevel.Code;
if (includeMessages)
expected |= ExceptionLevel.Message;
Assert.Equal(expected, options.Server.ExceptionLevel);
Assert.Equal(includeMessages, options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Message));
Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Source));
Assert.False(options.Server.ExceptionLevel.HasFlag(ExceptionLevel.Trace));
}
[Fact]
public async Task MappedEndpoint_DoesNotAffectOrdinaryHttp_AndRequiresWebSocketUpgrade()
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var healthResponse = await client.GetAsync("/health");
Assert.Equal(HttpStatusCode.OK, healthResponse.StatusCode);
Assert.Equal("healthy", await healthResponse.Content.ReadAsStringAsync());
using var endpointResponse = await client.GetAsync("/esiur");
Assert.Equal(HttpStatusCode.UpgradeRequired, endpointResponse.StatusCode);
Assert.Equal("websocket", endpointResponse.Headers.GetValues("Upgrade").Single());
}
[Theory]
[InlineData(null)]
[InlineData("not-ep")]
public async Task MappedEndpoint_RejectsWebSocketHandshakeWithoutEpSubProtocol(
string? protocol)
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var request = CreateWebSocketUpgradeRequest("/esiur", protocol);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains(
$"'{FrameworkWebSocket.SubProtocol}' WebSocket subprotocol is required",
await response.Content.ReadAsStringAsync(),
StringComparison.Ordinal);
}
[Fact]
public async Task MappedEndpoint_RejectsCrossOriginBrowserHandshakeByDefault()
{
await using var host = await StartApplicationAsync();
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var request = CreateWebSocketUpgradeRequest(
"/esiur",
FrameworkWebSocket.SubProtocol);
request.Headers.TryAddWithoutValidation("Origin", "https://untrusted.example");
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task MappedEndpoint_ExplicitOriginAllowlist_ReplacesImplicitSameOriginPolicy()
{
const string trustedOrigin = "https://trusted.example";
await using var host = await StartApplicationAsync(
configureEsiur: builder => builder.AllowWebSocketOrigins(trustedOrigin));
using var cancellation = new CancellationTokenSource(TestTimeout);
using var client = new HttpClient { BaseAddress = host.HttpAddress };
using var sameOriginRequest = CreateWebSocketUpgradeRequest(
"/esiur",
FrameworkWebSocket.SubProtocol);
sameOriginRequest.Headers.TryAddWithoutValidation(
"Origin",
host.HttpAddress.GetLeftPart(UriPartial.Authority));
using var sameOriginResponse = await client.SendAsync(
sameOriginRequest,
cancellation.Token);
Assert.Equal(HttpStatusCode.Forbidden, sameOriginResponse.StatusCode);
using var trustedSocket = new ClientWebSocket();
trustedSocket.Options.AddSubProtocol(FrameworkWebSocket.SubProtocol);
trustedSocket.Options.SetRequestHeader("Origin", trustedOrigin);
await trustedSocket.ConnectAsync(host.WebSocketAddress, cancellation.Token);
Assert.Equal(WebSocketState.Open, trustedSocket.State);
Assert.Equal(FrameworkWebSocket.SubProtocol, trustedSocket.SubProtocol);
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
trustedSocket.Abort();
await WaitUntilAsync(
() => host.Server.Connections.Count == 0,
cancellation.Token);
}
[Fact]
public async Task MappedEndpoint_AppliesConfiguredPendingSendLimitToAcceptedSocket()
{
const long pendingSendLimit = 4 * 1024;
await using var host = await StartApplicationAsync(
configureEsiur: builder =>
builder.LimitPendingWebSocketSendBytes(pendingSendLimit));
using var cancellation = new CancellationTokenSource(TestTimeout);
var clientSocket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await clientSocket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.True(clientSocket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
var connection = Assert.Single(host.Server.Connections);
var acceptedSocket = Assert.IsType<FrameworkWebSocket>(connection.Socket);
Assert.Equal(pendingSendLimit, acceptedSocket.MaximumPendingSendBytes);
Assert.NotNull(acceptedSocket.PendingSendBudget);
}
finally
{
clientSocket.Destroy();
}
await WaitUntilAsync(
() => host.Server.Connections.Count == 0,
cancellation.Token);
}
[Fact]
public async Task FrameworkWebSocket_ConnectsThroughKestrel_WithRealPeerAdmission()
{
await using var host = await StartApplicationAsync(
configureWarehouse: configuration =>
configuration.Connections.MaximumConnectionsPerIpAddress = 1);
using var cancellation = new CancellationTokenSource(TestTimeout);
var socket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.Equal(SocketState.Established, socket.State);
Assert.True(socket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
var connection = Assert.Single(host.Server.Connections);
Assert.Equal(IPAddress.Loopback, connection.RemoteEndPoint.Address);
Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback));
var rejectedSocket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
// The HTTP upgrade can complete before the EP admission decision closes the
// second transport. What matters is that it is never added to the server.
if (await rejectedSocket.Connect(host.WebSocketAddress, cancellation.Token))
rejectedSocket.Begin();
await WaitUntilAsync(
() => rejectedSocket.State == SocketState.Closed,
cancellation.Token);
Assert.Single(host.Server.Connections);
Assert.Equal(1, host.Server.GetConnectionCount(IPAddress.Loopback));
}
finally
{
rejectedSocket.Destroy();
}
}
finally
{
socket.Destroy();
}
using var cleanupCancellation = new CancellationTokenSource(TestTimeout);
await WaitUntilAsync(
() => host.Server.Connections.Count == 0
&& host.Server.GetConnectionCount(IPAddress.Loopback) == 0,
cleanupCancellation.Token);
}
[Fact]
public async Task HostShutdown_CancelsWebSocketAndCleansUpAdmission()
{
await using var host = await StartApplicationAsync();
using var cancellation = new CancellationTokenSource(TestTimeout);
var socket = new FrameworkWebSocket(host.WebSocketAddress);
try
{
Assert.True(await socket.Connect(host.WebSocketAddress, cancellation.Token));
Assert.True(socket.Begin());
await WaitUntilAsync(
() => host.Server.Connections.Count == 1,
cancellation.Token);
await host.Application.StopAsync(cancellation.Token);
Assert.True(socket.Completion.IsCompleted);
Assert.Equal(SocketState.Closed, socket.State);
Assert.Empty(host.Server.Connections);
Assert.Equal(0, host.Server.GetConnectionCount(IPAddress.Loopback));
}
finally
{
socket.Destroy();
}
}
[Fact]
public async Task AspNetHosting_DoesNotOpenTheNativeEpTcpListener()
{
var nativePort = GetUnusedTcpPort();
await using var host = await StartApplicationAsync(
configureServer: server => server.Port = checked((ushort)nativePort));
Assert.False(host.Server.EnableTcpListener);
Assert.False(host.Server.IsRunning);
var probe = new TcpListener(IPAddress.Loopback, nativePort);
try
{
probe.Start();
}
finally
{
probe.Stop();
}
}
[Fact]
public async Task ExternalWarehouse_MustBeOpenBeforeEsiurStarts()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var server = await warehouse.Put("sys/server", new EpServer
{
EnableTcpListener = false,
AuthenticationTimeout = TestTimeout,
});
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
builder.Services
.AddEsiur(warehouse, server, manageWarehouseLifecycle: false)
.UsePasswordAuthentication((_, _) => null);
await using var application = builder.Build();
application.UseWebSockets();
application.MapEsiur();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains(
"externally managed Warehouse must be open",
exception.Message,
StringComparison.Ordinal);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Empty(server.AllowedAuthenticationProviders);
}
[Fact]
public async Task ExternalWarehouse_DoesNotRetainFacadeAuthenticationOnShutdown()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var server = await warehouse.Put("sys/server", new EpServer
{
EnableTcpListener = false,
AuthenticationTimeout = TestTimeout,
});
await warehouse.Open();
var builder = WebApplication.CreateBuilder();
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
builder.Services
.AddEsiur(warehouse, server, manageWarehouseLifecycle: false)
.UsePasswordAuthentication((_, _) => null);
await using var application = builder.Build();
application.UseWebSockets();
application.MapEsiur();
try
{
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
Assert.NotNull(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Equal(
new[] { PasswordAuthenticationProvider.ProtocolName },
server.AllowedAuthenticationProviders);
await application.StopAsync(cancellation.Token);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
Assert.Empty(server.AllowedAuthenticationProviders);
Assert.True(warehouse.IsOpen);
}
finally
{
if (warehouse.IsOpen)
await warehouse.Close();
}
}
[Fact]
public async Task PasswordShortcut_UsesValidatedNonAliasedCredentialsAndUnlinkableDummies()
{
var knownHash = Enumerable.Range(1, 32).Select(value => (byte)value).ToArray();
var knownSalt = Enumerable.Range(33, 32).Select(value => (byte)value).ToArray();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.UsePasswordAuthentication((identity, _) => identity switch
{
"known" => new PasswordHash(knownHash, knownSalt),
"bad-hash" => new PasswordHash(new byte[31], new byte[32]),
"bad-salt" => new PasswordHash(new byte[32], new byte[31]),
"partial" => new PasswordHash(new byte[32], null!),
_ => null,
}));
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var warehouse = application.Services.GetRequiredService<Warehouse>();
var provider = Assert.IsAssignableFrom<PasswordAuthenticationProvider>(
warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
var firstUnknown = provider.GetHostedAccountCredential("missing", "example");
var secondUnknown = provider.GetHostedAccountCredential("missing", "example");
var otherUnknown = provider.GetHostedAccountCredential("other", "example");
var originalDummyHash = (byte[])firstUnknown.Hash.Clone();
var originalDummySalt = (byte[])firstUnknown.Salt.Clone();
Assert.Equal(32, firstUnknown.Hash.Length);
Assert.Equal(32, firstUnknown.Salt.Length);
Assert.Equal(originalDummyHash, secondUnknown.Hash);
Assert.Equal(originalDummySalt, secondUnknown.Salt);
Assert.NotSame(firstUnknown.Hash, secondUnknown.Hash);
Assert.NotSame(firstUnknown.Salt, secondUnknown.Salt);
Assert.NotEqual(originalDummyHash, otherUnknown.Hash);
Assert.NotEqual(originalDummySalt, otherUnknown.Salt);
Assert.NotEqual(knownHash, originalDummyHash);
Assert.NotEqual(knownSalt, originalDummySalt);
firstUnknown.Hash[0] ^= 0xff;
firstUnknown.Salt[0] ^= 0xff;
var thirdUnknown = provider.GetHostedAccountCredential("missing", "example");
Assert.Equal(originalDummyHash, thirdUnknown.Hash);
Assert.Equal(originalDummySalt, thirdUnknown.Salt);
var firstKnown = provider.GetHostedAccountCredential("known", "example");
Assert.Equal(knownHash, firstKnown.Hash);
Assert.Equal(knownSalt, firstKnown.Salt);
Assert.NotSame(knownHash, firstKnown.Hash);
Assert.NotSame(knownSalt, firstKnown.Salt);
firstKnown.Hash[0] ^= 0xff;
firstKnown.Salt[0] ^= 0xff;
var secondKnown = provider.GetHostedAccountCredential("known", "example");
Assert.Equal(knownHash, secondKnown.Hash);
Assert.Equal(knownSalt, secondKnown.Salt);
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("bad-hash", "example"));
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("bad-salt", "example"));
Assert.Throws<InvalidOperationException>(() =>
provider.GetHostedAccountCredential("partial", "example"));
Assert.Null(provider.GetHostedAccountCredential(
new string('i', 513),
"example").Hash);
Assert.Null(provider.GetHostedAccountCredential(
"missing",
new string('d', 513)).Hash);
}
[Fact]
public async Task StartupFailure_RollsBackResourcesOwnedByTheFacade()
{
var attachedBeforeFailure = new LifecycleTestResource();
var authenticationProvider = new PasswordAuthenticationProvider();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AddResource("sys/first", attachedBeforeFailure)
.AddResource<LifecycleTestResource>(
"sys/failure",
_ => throw new InvalidOperationException("factory failed"))
.UseAuthentication(authenticationProvider));
var warehouse = application.Services.GetRequiredService<Warehouse>();
var server = application.Services.GetRequiredService<EpServer>();
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => application.StartAsync().WaitAsync(TestTimeout));
Assert.Contains("factory failed", exception.Message, StringComparison.Ordinal);
Assert.Null(attachedBeforeFailure.Instance);
Assert.Null(warehouse.GetStore("sys"));
Assert.Null(server.Instance);
Assert.False(warehouse.IsOpen);
Assert.Equal(0, attachedBeforeFailure.TerminationCount);
Assert.True(server.EnableTcpListener);
Assert.Empty(server.AllowedAuthenticationProviders);
Assert.Null(warehouse.TryGetAuthenticationProvider(
PasswordAuthenticationProvider.ProtocolName));
}
[Fact]
public async Task StartupCancellationDuringWarehouseOpen_CompletesRollback()
{
var resource = new BlockingStartupResource();
await using var application = BuildApplication(builder => builder
.AddMemoryStore("sys")
.AddResource("sys/blocking", resource)
.AllowAnonymous());
using var cancellation = new CancellationTokenSource();
var start = application.StartAsync(cancellation.Token);
await resource.InitializeStarted.WaitAsync(TestTimeout);
cancellation.Cancel();
resource.ReleaseInitialize();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => start.WaitAsync(TestTimeout));
Assert.Equal(1, resource.TerminationCount);
Assert.Null(resource.Instance);
}
[Fact]
public async Task HostShutdown_ObservesCancellationWhenResourceTerminationStalls()
{
var stalledResource = new LifecycleTestResource(stallTermination: true);
await using var host = await StartApplicationAsync(
configureEsiur: builder =>
builder.AddResource("sys/stalled", stalledResource));
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
var stop = host.Application.StopAsync(cancellation.Token);
await Task.Delay(TimeSpan.FromMilliseconds(200));
stalledResource.ReleaseTermination();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => stop);
Assert.False(host.Server.IsRunning);
Assert.Empty(host.Server.Connections);
Assert.Null(stalledResource.Instance);
Assert.False(host.Application.Services.GetRequiredService<Warehouse>().IsOpen);
}
private static WebApplication BuildApplication(
Action<EsiurBuilder>? configureEsiur = null,
Action<EpServer>? configureServer = null,
Action<WarehouseConfiguration>? configureWarehouse = null)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
EnvironmentName = Environments.Development,
});
builder.WebHost.ConfigureKestrel(options =>
options.Listen(IPAddress.Loopback, 0));
var warehouse = new Warehouse();
var server = new EpServer
{
AuthenticationTimeout = TestTimeout,
};
var esiur = builder.Services.AddEsiur(warehouse, server);
configureEsiur?.Invoke(esiur);
if (configureServer is not null)
esiur.ConfigureServer(configureServer);
if (configureWarehouse is not null)
esiur.ConfigureWarehouse(configureWarehouse);
var application = builder.Build();
application.UseWebSockets();
application.MapGet("/health", () => Results.Text("healthy"));
application.MapEsiur("/esiur");
return application;
}
private static async Task<TestApplication> StartApplicationAsync(
Action<EpServer>? configureServer = null,
Action<WarehouseConfiguration>? configureWarehouse = null,
Action<EsiurBuilder>? configureEsiur = null)
{
var application = BuildApplication(
esiur =>
{
esiur.AddMemoryStore("sys").AllowAnonymous();
configureEsiur?.Invoke(esiur);
},
configureServer,
configureWarehouse);
using var cancellation = new CancellationTokenSource(TestTimeout);
await application.StartAsync(cancellation.Token);
var addresses = application.Services
.GetRequiredService<IServer>()
.Features
.Get<IServerAddressesFeature>()
?.Addresses;
var address = Assert.Single(addresses!);
var httpAddress = new Uri(address);
var webSocketAddress = new UriBuilder(httpAddress)
{
Scheme = "ws",
Path = "/esiur",
}.Uri;
return new TestApplication(application, httpAddress, webSocketAddress);
}
private static HttpRequestMessage CreateWebSocketUpgradeRequest(
string path,
string? protocol)
{
var request = new HttpRequestMessage(HttpMethod.Get, path)
{
Version = HttpVersion.Version11,
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
request.Headers.TryAddWithoutValidation("Connection", "Upgrade");
request.Headers.TryAddWithoutValidation("Upgrade", "websocket");
request.Headers.TryAddWithoutValidation("Sec-WebSocket-Version", "13");
request.Headers.TryAddWithoutValidation(
"Sec-WebSocket-Key",
Convert.ToBase64String(Guid.NewGuid().ToByteArray()));
if (protocol is not null)
request.Headers.TryAddWithoutValidation("Sec-WebSocket-Protocol", protocol);
return request;
}
private static async Task WaitUntilAsync(
Func<bool> condition,
CancellationToken cancellationToken)
{
while (!condition())
await Task.Delay(10, cancellationToken);
}
private static int GetUnusedTcpPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
private sealed class TestApplication : IAsyncDisposable
{
public TestApplication(
WebApplication application,
Uri httpAddress,
Uri webSocketAddress)
{
Application = application;
HttpAddress = httpAddress;
WebSocketAddress = webSocketAddress;
Server = application.Services.GetRequiredService<EpServer>();
}
public WebApplication Application { get; }
public Uri HttpAddress { get; }
public Uri WebSocketAddress { get; }
public EpServer Server { get; }
public async ValueTask DisposeAsync()
{
try
{
using var cancellation = new CancellationTokenSource(TestTimeout);
await Application.StopAsync(cancellation.Token);
}
finally
{
await Application.DisposeAsync();
}
}
}
private sealed class LifecycleTestResource : IResource
{
private readonly bool stallTermination;
private readonly AsyncReply<bool> termination = new();
private int terminationCount;
public LifecycleTestResource(bool stallTermination = false)
{
this.stallTermination = stallTermination;
}
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public int TerminationCount => Volatile.Read(ref terminationCount);
public void ReleaseTermination() => termination.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Terminate)
{
Interlocked.Increment(ref terminationCount);
if (stallTermination)
return termination;
}
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
private sealed class BlockingStartupResource : IResource
{
private readonly AsyncReply<bool> initialize = new();
private readonly TaskCompletionSource initializeStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private int terminationCount;
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task InitializeStarted => initializeStarted.Task;
public int TerminationCount => Volatile.Read(ref terminationCount);
public void ReleaseInitialize() => initialize.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Initialize)
{
initializeStarted.TrySetResult();
return initialize;
}
if (operation == ResourceOperation.Terminate)
Interlocked.Increment(ref terminationCount);
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
}
+233
View File
@@ -0,0 +1,233 @@
using System.Net;
using Esiur.Core;
using Esiur.Net;
using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Resource;
namespace Esiur.Tests.Unit;
public sealed class EpConnectionReconnectTests
{
[Fact]
public async Task InitialAutoReconnect_CreatesAFreshSocketAfterConnectFailure()
{
var failedSocket = new ConnectTestSocket(connects: false);
var connectedSocket = new ConnectTestSocket(connects: true);
var sockets = new Queue<ConnectTestSocket>(
new[] { failedSocket, connectedSocket });
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 0,
ClientSocketFactory = () => sockets.Dequeue(),
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
var completed = await Task.WhenAny(
connectedSocket.Began,
Task.Delay(TimeSpan.FromSeconds(2)));
Assert.True(
ReferenceEquals(completed, connectedSocket.Began),
$"Fresh socket was not started (remaining={sockets.Count}, " +
$"failed-connects={failedSocket.ConnectCount}, " +
$"replacement-connects={connectedSocket.ConnectCount}, " +
$"open-error={open.Exception?.Message}).");
Assert.Equal(1, failedSocket.ConnectCount);
Assert.Equal(1, connectedSocket.ConnectCount);
Assert.Equal(1, connectedSocket.BeginCount);
Assert.Same(connectedSocket, connection.Socket);
Assert.Empty(sockets);
connection.Destroy();
}
[Fact]
public async Task DestroyDuringPendingConnect_DoesNotAttachLateSocket()
{
var delayedSocket = new ConnectTestSocket(connects: null);
var connection = new EpConnection
{
ClientSocketFactory = () => delayedSocket,
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
await delayedSocket.ConnectInvoked.WaitAsync(TimeSpan.FromSeconds(2));
connection.Destroy();
delayedSocket.CompleteConnect(true);
Assert.Null(connection.Socket);
Assert.Equal(0, delayedSocket.BeginCount);
Assert.Equal(SocketState.Closed, delayedSocket.State);
Assert.NotNull(open.Exception);
}
[Fact]
public async Task DisablingAutoReconnect_CompletesDelayedOpenAndAllowsAnotherConnect()
{
var failedSocket = new ConnectTestSocket(connects: false);
var factoryCalls = 0;
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 30,
ClientSocketFactory = () =>
{
factoryCalls++;
return failedSocket;
},
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
var open = connection.Connect(
hostname: "localhost",
port: 10518,
domain: "test");
connection.AutoReconnect = false;
Assert.NotNull(open.Exception);
Assert.Equal(EpConnectionStatus.Closed, connection.Status);
Assert.Equal(1, factoryCalls);
var replacement = new ConnectTestSocket(connects: true);
var secondOpen = connection.Connect(replacement);
Assert.Null(secondOpen.Exception);
Assert.Same(replacement, connection.Socket);
connection.Destroy();
}
[Fact]
public async Task DisablingAutoReconnectDuringDisconnectDelay_PreventsReconnect()
{
var initialSocket = new ConnectTestSocket(connects: true);
var factoryCalls = 0;
var connection = new EpConnection
{
AutoReconnect = true,
ReconnectInterval = 1,
ClientSocketFactory = () =>
{
factoryCalls++;
return new ConnectTestSocket(connects: true);
},
};
var warehouse = new Warehouse();
await warehouse.Put("client", connection);
_ = connection.Connect(
initialSocket,
hostname: "localhost",
port: 10518,
domain: "test");
initialSocket.Disconnect();
connection.AutoReconnect = false;
await Task.Delay(TimeSpan.FromMilliseconds(1_200));
Assert.Equal(0, factoryCalls);
connection.Destroy();
}
private sealed class ConnectTestSocket : ISocket
{
private readonly bool? connects;
private readonly TaskCompletionSource began = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource connectInvoked = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private AsyncReply<bool>? pendingConnect;
private SocketState state = SocketState.Initial;
public ConnectTestSocket(bool? connects) => this.connects = connects;
public event DestroyedEvent? OnDestroy;
public SocketState State => state;
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
public IPEndPoint RemoteEndPoint { get; } =
new(IPAddress.Loopback, 10518);
public IPEndPoint LocalEndPoint { get; } =
new(IPAddress.Loopback, 50000);
public int ConnectCount { get; private set; }
public int BeginCount { get; private set; }
public Task Began => began.Task;
public Task ConnectInvoked => connectInvoked.Task;
public AsyncReply<bool> Connect(string hostname, ushort port)
{
ConnectCount++;
connectInvoked.TrySetResult();
if (connects is null)
{
pendingConnect = new AsyncReply<bool>();
return pendingConnect;
}
if (connects.Value)
{
state = SocketState.Established;
return new AsyncReply<bool>(true);
}
state = SocketState.Closed;
var reply = new AsyncReply<bool>();
reply.TriggerError(new InvalidOperationException("connect failed"));
return reply;
}
public void CompleteConnect(bool connected)
{
var reply = pendingConnect
?? throw new InvalidOperationException("No connection is pending.");
pendingConnect = null;
state = connected ? SocketState.Established : SocketState.Closed;
reply.Trigger(connected);
}
public void Disconnect()
{
state = SocketState.Closed;
Receiver.NetworkClose(this);
}
public bool Begin()
{
BeginCount++;
began.TrySetResult();
return state == SocketState.Established;
}
public AsyncReply<bool> BeginAsync() => new(Begin());
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length) =>
new(state == SocketState.Established);
public void Send(byte[] message) { }
public void Send(byte[] message, int offset, int length) { }
public void Hold() { }
public void Unhold() { }
public void Close() => state = SocketState.Closed;
public void Destroy()
{
state = SocketState.Closed;
OnDestroy?.Invoke(this);
}
public AsyncReply<ISocket> AcceptAsync() =>
throw new NotSupportedException();
public ISocket Accept() => throw new NotSupportedException();
}
}
+6 -1
View File
@@ -14,6 +14,10 @@
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
@@ -22,6 +26,7 @@
mirroring Tests/Features/Functional so [Resource]/[Export] test types get generated code. -->
<ItemGroup>
<ProjectReference Include="..\..\Libraries\Esiur\Esiur.csproj" OutputItemType="Analyzer" />
<ProjectReference Include="..\..\Integrations\Esiur.AspNetCore\Esiur.AspNetCore.csproj" />
</ItemGroup>
</Project>
</Project>
+604
View File
@@ -0,0 +1,604 @@
using Esiur.Net;
using Esiur.Net.Sockets;
using System.Collections.Concurrent;
using System.Net;
using System.Net.WebSockets;
namespace Esiur.Tests.Unit;
public class FrameworkWebSocketTests
{
[Fact]
public async Task AcceptedSocket_ReceivesBinaryDataAndCompletesOnceOnPeerClose()
{
var platformSocket = new TestWebSocket();
platformSocket.QueueBinary(new byte[] { 1, 2, 3 });
platformSocket.QueueClose();
var local = new IPEndPoint(IPAddress.Loopback, 443);
var remote = new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123);
var socket = new FrameworkWebSocket(platformSocket, local, remote);
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(local, socket.LocalEndPoint);
Assert.Equal(remote, socket.RemoteEndPoint);
Assert.Equal(new byte[] { 1, 2, 3 }, Assert.Single(receiver.Messages));
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(1, platformSocket.CloseOutputCount);
socket.Close();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("ep")]
[InlineData("not-ep")]
public void AcceptedSocket_RequiresExactEpSubprotocol(string? subProtocol)
{
var platformSocket = new TestWebSocket(subProtocol);
var exception = Assert.Throws<ArgumentException>(() =>
new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Parse("192.0.2.10"), 55123)));
Assert.Contains("'EP' subprotocol", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task OwnerCancellation_EndsReceiveLoopWithoutFaultOrDuplicateClose()
{
var platformSocket = new TestWebSocket();
using var cancellation = new CancellationTokenSource();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000),
cancellation.Token);
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
cancellation.Cancel();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
socket.Close();
socket.Destroy();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(SocketState.Closed, socket.State);
}
[Fact]
public async Task LocalClose_WaitsForPeerAcknowledgementBeforeCompleting()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
var completion = socket.CloseAsync();
await Task.Delay(25);
Assert.False(completion.IsCompleted);
Assert.Equal(1, platformSocket.CloseOutputCount);
platformSocket.QueueClose();
await completion.WaitAsync(TimeSpan.FromSeconds(2));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(1, receiver.CloseCount);
Assert.Equal(SocketState.Closed, socket.State);
}
[Fact]
public async Task SimultaneousPeerClose_AcknowledgesAfterAnInFlightSend()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
socket.Receiver = new RecordingReceiver();
Assert.True(socket.Begin());
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var completion = socket.CloseAsync();
platformSocket.QueueClose();
platformSocket.ReleaseSends();
await completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(await send);
Assert.Equal(1, platformSocket.CloseOutputCount);
}
[Fact]
public async Task SendQueue_OverflowAbortsOnceAndSettlesEveryReply()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
MaximumPendingSendBytes = 4,
};
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
var first = socket.SendAsync(new byte[] { 1, 2 }, 0, 2);
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var second = socket.SendAsync(new byte[] { 3, 4 }, 0, 2);
var overflow = socket.SendAsync(new byte[] { 5 }, 0, 1);
Assert.True(overflow.Failed);
Assert.Contains("send queue exceeded", overflow.Exception?.Message);
Assert.True(second.Failed);
Assert.Contains("send queue exceeded", second.Exception?.Message);
Assert.False(await first);
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
Assert.Empty(platformSocket.SentMessages);
var later = socket.SendAsync(new byte[] { 6 }, 0, 1);
Assert.True(later.Ready);
Assert.False(await later);
socket.Destroy();
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
}
[Fact]
public async Task SynchronousSend_OverflowAbortsBeforeThrowing()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
MaximumPendingSendBytes = 1,
};
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
socket.Hold();
socket.Send(new byte[] { 1 }, 0, 1);
var error = Assert.Throws<InvalidOperationException>(() =>
socket.Send(new byte[] { 2 }, 0, 1));
Assert.Contains("send queue exceeded", error.Message);
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(SocketState.Closed, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(1, platformSocket.AbortCount);
Assert.Equal(1, receiver.CloseCount);
await Assert.ThrowsAsync<InvalidOperationException>(() => socket.Completion);
}
[Fact]
public async Task SharedSendBudget_BoundsAggregateQueuedBytesAcrossSockets()
{
var budget = new TestPendingSendBudget(3);
var firstPlatform = new TestWebSocket();
firstPlatform.BlockSends();
var firstSocket = CreateAcceptedSocket(firstPlatform, budget);
var secondSocket = CreateAcceptedSocket(new TestWebSocket(), budget);
var firstSend = firstSocket.SendAsync(new byte[] { 1, 2 }, 0, 2);
await firstPlatform.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
var overflow = secondSocket.SendAsync(new byte[] { 3, 4 }, 0, 2);
Assert.True(overflow.Failed);
Assert.Contains("host-wide", overflow.Exception?.Message);
Assert.Equal(SocketState.Closed, secondSocket.State);
Assert.Equal(2, budget.ReservedBytes);
firstSocket.Destroy();
Assert.False(await firstSend);
await firstSocket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.Equal(0, budget.ReservedBytes);
await Assert.ThrowsAsync<InvalidOperationException>(() => secondSocket.Completion);
}
[Fact]
public async Task Completion_DoesNotWaitForAnInProgressSendReplyCallback()
{
var platformSocket = new TestWebSocket();
platformSocket.BlockSends();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
try
{
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
_ = send.Then(_ =>
{
callbackStarted.Set();
releaseCallback.Wait();
});
await platformSocket.SendStarted.WaitAsync(TimeSpan.FromSeconds(2));
platformSocket.ReleaseSends();
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(releaseCallback.IsSet);
Assert.True(send.Ready);
Assert.True(await send);
}
finally
{
releaseCallback.Set();
socket.Destroy();
}
}
[Fact]
public async Task Destroy_DoesNotWaitForABlockedQueuedSendReplyCallback()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
try
{
socket.Hold();
var send = socket.SendAsync(new byte[] { 1 }, 0, 1);
_ = send.Then(_ =>
{
callbackStarted.Set();
releaseCallback.Wait();
});
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(send.Ready);
Assert.False(await send);
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
Assert.False(releaseCallback.IsSet);
}
finally
{
releaseCallback.Set();
socket.Destroy();
}
}
[Fact]
public async Task TextMessage_FaultsCompletionAndNotifiesCloseOnce()
{
var platformSocket = new TestWebSocket();
platformSocket.QueueText("not EP");
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
var receiver = new RecordingReceiver();
socket.Receiver = receiver;
Assert.True(socket.Begin());
await Assert.ThrowsAsync<InvalidDataException>(async () =>
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2)));
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
socket.Close();
socket.Destroy();
Assert.Equal(1, receiver.CloseCount);
}
[Fact]
public async Task Completion_DoesNotWaitForABlockedReceiverCloseCallback()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
using var callbackStarted = new ManualResetEventSlim();
using var releaseCallback = new ManualResetEventSlim();
socket.Receiver = new BlockingCloseReceiver(callbackStarted, releaseCallback);
try
{
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
Assert.True(callbackStarted.Wait(TimeSpan.FromSeconds(2)));
Assert.False(socket.CloseNotification.IsCompleted);
}
finally
{
releaseCallback.Set();
}
await socket.CloseNotification.WaitAsync(TimeSpan.FromSeconds(2));
}
[Fact]
public async Task ThrowingSendCallback_DoesNotCorruptQueueOrCloseTransport()
{
var platformSocket = new TestWebSocket();
var socket = new FrameworkWebSocket(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000));
socket.Hold();
var reply = socket.SendAsync(new byte[] { 42 }, 0, 1);
_ = reply.Then(_ => throw new InvalidOperationException("consumer callback failed"));
socket.Unhold();
Assert.True(await reply);
Assert.Equal(SocketState.Established, socket.State);
Assert.Equal(0, socket.PendingSendBytes);
Assert.Equal(new byte[] { 42 }, Assert.Single(platformSocket.SentMessages));
socket.Destroy();
await socket.Completion.WaitAsync(TimeSpan.FromSeconds(2));
}
[Fact]
public void OutboundUri_RequiresWebSocketScheme()
{
Assert.Throws<ArgumentException>(() =>
new FrameworkWebSocket(new Uri("https://example.test/esiur")));
_ = new FrameworkWebSocket(new Uri("wss://example.test/esiur?tenant=one"));
Assert.Equal("EP", FrameworkWebSocket.SubProtocol);
}
private sealed class RecordingReceiver : INetworkReceiver<ISocket>
{
public List<byte[]> Messages { get; } = new List<byte[]>();
public int CloseCount;
public void NetworkClose(ISocket sender) => Interlocked.Increment(ref CloseCount);
public void NetworkConnect(ISocket sender)
{
}
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
{
var message = buffer.Read();
if (message != null)
Messages.Add(message);
}
}
private sealed class BlockingCloseReceiver : INetworkReceiver<ISocket>
{
private readonly ManualResetEventSlim callbackStarted;
private readonly ManualResetEventSlim releaseCallback;
public BlockingCloseReceiver(
ManualResetEventSlim callbackStarted,
ManualResetEventSlim releaseCallback)
{
this.callbackStarted = callbackStarted;
this.releaseCallback = releaseCallback;
}
public void NetworkClose(ISocket sender)
{
callbackStarted.Set();
releaseCallback.Wait();
}
public void NetworkConnect(ISocket sender)
{
}
public void NetworkReceive(ISocket sender, NetworkBuffer buffer)
{
}
}
private static FrameworkWebSocket CreateAcceptedSocket(
TestWebSocket platformSocket,
IPendingSendBudget budget)
=> new(
platformSocket,
new IPEndPoint(IPAddress.Loopback, 443),
new IPEndPoint(IPAddress.Loopback, 50000))
{
PendingSendBudget = budget,
};
private sealed class TestPendingSendBudget : IPendingSendBudget
{
private readonly long limit;
private long reservedBytes;
public TestPendingSendBudget(long limit) => this.limit = limit;
public long ReservedBytes => Interlocked.Read(ref reservedBytes);
public bool TryReserve(int byteCount)
{
while (true)
{
var current = ReservedBytes;
if (byteCount > limit - current)
return false;
if (Interlocked.CompareExchange(
ref reservedBytes,
current + byteCount,
current) == current)
return true;
}
}
public void Release(int byteCount) =>
Interlocked.Add(ref reservedBytes, -byteCount);
}
private sealed class TestWebSocket : WebSocket
{
private readonly ConcurrentQueue<ReceiveFrame> receiveFrames = new();
private readonly SemaphoreSlim receiveSignal = new(0);
private WebSocketState state = WebSocketState.Open;
private WebSocketCloseStatus? closeStatus;
private string? closeStatusDescription;
private readonly string? subProtocol;
private TaskCompletionSource? sendStarted;
private TaskCompletionSource? sendRelease;
public TestWebSocket(string? subProtocol = FrameworkWebSocket.SubProtocol)
=> this.subProtocol = subProtocol;
public List<byte[]> SentMessages { get; } = new List<byte[]>();
public int AbortCount;
public int CloseOutputCount;
public Task SendStarted => sendStarted?.Task ?? Task.CompletedTask;
public void BlockSends()
{
sendStarted = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
sendRelease = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
}
public void ReleaseSends() => sendRelease?.TrySetResult();
public override WebSocketCloseStatus? CloseStatus => closeStatus;
public override string? CloseStatusDescription => closeStatusDescription;
public override WebSocketState State => state;
public override string? SubProtocol => subProtocol;
public void QueueBinary(byte[] data) =>
Queue(new ReceiveFrame(data, WebSocketMessageType.Binary, true));
public void QueueText(string data) =>
Queue(new ReceiveFrame(
System.Text.Encoding.UTF8.GetBytes(data),
WebSocketMessageType.Text,
true));
public void QueueClose() =>
Queue(new ReceiveFrame(Array.Empty<byte>(), WebSocketMessageType.Close, true));
public override void Abort()
{
Interlocked.Increment(ref AbortCount);
state = WebSocketState.Aborted;
}
public override Task CloseAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
this.closeStatus = closeStatus;
closeStatusDescription = statusDescription;
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override Task CloseOutputAsync(
WebSocketCloseStatus closeStatus,
string? statusDescription,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref CloseOutputCount);
this.closeStatus = closeStatus;
closeStatusDescription = statusDescription;
state = WebSocketState.Closed;
return Task.CompletedTask;
}
public override void Dispose() => state = WebSocketState.Closed;
public override async Task<WebSocketReceiveResult> ReceiveAsync(
ArraySegment<byte> buffer,
CancellationToken cancellationToken)
{
await receiveSignal.WaitAsync(cancellationToken);
if (!receiveFrames.TryDequeue(out var frame))
throw new InvalidOperationException("The receive signal had no frame.");
if (frame.MessageType == WebSocketMessageType.Close)
{
state = WebSocketState.CloseReceived;
closeStatus = WebSocketCloseStatus.NormalClosure;
}
Buffer.BlockCopy(frame.Data, 0, buffer.Array!, buffer.Offset, frame.Data.Length);
return new WebSocketReceiveResult(
frame.Data.Length,
frame.MessageType,
frame.EndOfMessage,
frame.MessageType == WebSocketMessageType.Close
? WebSocketCloseStatus.NormalClosure
: null,
null);
}
public override async Task SendAsync(
ArraySegment<byte> buffer,
WebSocketMessageType messageType,
bool endOfMessage,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
sendStarted?.TrySetResult();
if (sendRelease is not null)
await sendRelease.Task.WaitAsync(cancellationToken);
var copy = new byte[buffer.Count];
Buffer.BlockCopy(buffer.Array!, buffer.Offset, copy, 0, buffer.Count);
SentMessages.Add(copy);
}
private void Queue(ReceiveFrame frame)
{
receiveFrames.Enqueue(frame);
receiveSignal.Release();
}
private readonly record struct ReceiveFrame(
byte[] Data,
WebSocketMessageType MessageType,
bool EndOfMessage);
}
}
+3 -1
View File
@@ -265,7 +265,9 @@ internal sealed class IntegrationCluster : IAsyncDisposable
? encryptionMode
: EncryptionMode.None,
EncryptionProviders = new[] { AesEncryptionProvider.Name },
UseWebSocket = useWebSocket,
WebSocketUri = useWebSocket
? new Uri($"ws://127.0.0.1:{port}")
: null,
});
return cluster;
@@ -5,6 +5,7 @@ using Esiur.Net.Sockets;
using Esiur.Protocol;
using Esiur.Security.Authority.Providers;
using Esiur.Security.Cryptography;
using System.Net;
[Collection("Integration")]
public class SessionHeadersIntegrationTests
@@ -248,16 +249,17 @@ public class SessionHeadersIntegrationTests
}
[Fact]
public async Task AddressBoundEncryption_RejectsWebSocketWithoutConcreteEndpoints()
public async Task AddressBoundEncryption_WorksAcrossWebSocketWithConcreteEndpoints()
{
var exception = await Assert.ThrowsAnyAsync<Exception>(async () =>
await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
useWebSocket: true)
.WaitAsync(TimeSpan.FromSeconds(5)));
await using var cluster = await IntegrationCluster.StartAsync(
_ => Task.CompletedTask,
encrypted: true,
encryptionMode: EncryptionMode.EncryptWithSessionKeyAndAddress,
useWebSocket: true)
.WaitAsync(TimeSpan.FromSeconds(10));
Assert.IsNotType<TimeoutException>(exception);
Assert.True(cluster.Connection.IsEncrypted);
Assert.True(Assert.Single(cluster.Server.Connections).IsEncrypted);
Assert.NotEqual(IPAddress.Any, cluster.Connection.RemoteEndPoint.Address);
}
}
@@ -0,0 +1,47 @@
using Esiur.Security.Authority.Providers;
namespace Esiur.Tests.Unit;
public sealed class PasswordAuthenticationProviderTests
{
[Fact]
public void ProtocolName_HasNoLegacyPublicAlias()
{
var publicProtocolConstants = typeof(PasswordAuthenticationProvider)
.GetFields(System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Static)
.Where(field => field.IsLiteral && field.FieldType == typeof(string))
.Select(field => Assert.IsType<string>(field.GetRawConstantValue()))
.ToArray();
Assert.Equal(new[] { PasswordAuthenticationProvider.ProtocolName },
publicProtocolConstants);
}
[Fact]
public void CreateCredential_GeneratesFreshSaltAndExpectedProtocolHash()
{
var password = new byte[] { 1, 2, 3, 4, 5 };
var first = PasswordAuthenticationProvider.CreateCredential(password);
var second = PasswordAuthenticationProvider.CreateCredential(password);
Assert.Equal(32, first.Salt.Length);
Assert.Equal(32, first.Hash.Length);
Assert.Equal(
PasswordAuthenticationHandler.ComputeSha3(
password.Concat(first.Salt).ToArray()),
first.Hash);
Assert.False(first.Salt.SequenceEqual(second.Salt));
Assert.Equal(new byte[] { 1, 2, 3, 4, 5 }, password);
}
[Fact]
public void CreateCredential_RejectsMissingOrEmptyPassword()
{
Assert.Throws<ArgumentNullException>(() =>
PasswordAuthenticationProvider.CreateCredential(null!));
Assert.Throws<ArgumentException>(() =>
PasswordAuthenticationProvider.CreateCredential(Array.Empty<byte>()));
}
}
+119 -3
View File
@@ -10,6 +10,45 @@ namespace Esiur.Tests.Unit;
public class PeerConnectionLimitTests
{
[Fact]
public async Task Server_CanTrackExternallyHostedConnectionsWithoutOpeningTcpListener()
{
var warehouse = new Warehouse();
var server = new EpServer
{
EnableTcpListener = false,
};
server.Instance = new Instance(warehouse, 1, "server", server, null);
Assert.True(await server.Handle(ResourceOperation.Initialize));
Assert.False(server.IsRunning);
Assert.Empty(server.Connections);
var socket = new TestSocket(IPAddress.Parse("192.0.2.5"), 10000);
var connection = new EpConnection();
connection.Assign(socket);
Assert.True(server.TryAdd(connection));
Assert.Single(server.Connections);
socket.Close();
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RequiresSocketAssignmentBeforeAdmission()
{
var warehouse = new Warehouse();
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
var error = Assert.Throws<InvalidOperationException>(
() => server.TryAdd(new EpConnection()));
Assert.Contains("Assign a socket", error.Message, StringComparison.Ordinal);
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RejectsConnectionsAbovePerIpLimit_AndReleasesClosedSlot()
{
@@ -24,12 +63,12 @@ public class PeerConnectionLimitTests
var firstSocket = new TestSocket(address, 10001);
var first = new EpConnection();
first.Assign(firstSocket);
server.Add(first);
Assert.True(server.TryAdd(first));
var rejectedSocket = new TestSocket(address, 10002);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
server.Add(rejected);
Assert.False(server.TryAdd(rejected));
Assert.Single(server.Connections);
Assert.Equal(SocketState.Closed, rejectedSocket.State);
@@ -41,7 +80,7 @@ public class PeerConnectionLimitTests
var replacementSocket = new TestSocket(address, 10003);
var replacement = new EpConnection();
replacement.Assign(replacementSocket);
server.Add(replacement);
Assert.True(server.TryAdd(replacement));
Assert.Equal(SocketState.Established, replacementSocket.State);
Assert.Equal(1, server.GetConnectionCount(address));
@@ -49,6 +88,49 @@ public class PeerConnectionLimitTests
replacementSocket.Close();
}
[Fact]
public void Server_RejectsConnectionsAboveGlobalLimitAcrossDifferentAddresses_AndReleasesSlot()
{
var warehouse = new Warehouse();
warehouse.Configuration.Connections.MaximumConnections = 2;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
warehouse.Configuration.Connections.MaximumConnectionAttempts = 0;
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
var firstSocket = new TestSocket(IPAddress.Parse("192.0.2.10"), 10101);
var first = new EpConnection();
first.Assign(firstSocket);
Assert.True(server.TryAdd(first));
var secondSocket = new TestSocket(IPAddress.Parse("192.0.2.11"), 10102);
var second = new EpConnection();
second.Assign(secondSocket);
Assert.True(server.TryAdd(second));
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.12"), 10103);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
Assert.False(server.TryAdd(rejected));
Assert.Equal(2, server.Connections.Count);
Assert.Equal(SocketState.Closed, rejectedSocket.State);
firstSocket.Close();
var replacementSocket = new TestSocket(IPAddress.Parse("192.0.2.13"), 10104);
var replacement = new EpConnection();
replacement.Assign(replacementSocket);
Assert.True(server.TryAdd(replacement));
Assert.Equal(2, server.Connections.Count);
secondSocket.Close();
replacementSocket.Close();
Assert.Empty(server.Connections);
}
[Fact]
public void Server_RejectsRepeatedConnectionsAbovePerIpAttemptRate()
{
@@ -88,6 +170,40 @@ public class PeerConnectionLimitTests
otherSocket.Close();
}
[Fact]
public void Server_RejectsRepeatedConnectionsAboveGlobalAttemptRateAcrossDifferentAddresses()
{
var warehouse = new Warehouse();
warehouse.Configuration.Connections.MaximumConnections = 0;
warehouse.Configuration.Connections.MaximumConnectionsPerIpAddress = 0;
warehouse.Configuration.Connections.MaximumConnectionAttempts = 2;
warehouse.Configuration.Connections.MaximumConnectionAttemptsPerIpAddress = 0;
warehouse.Configuration.Connections.ConnectionAttemptWindow = TimeSpan.FromMinutes(1);
var server = new EpServer();
server.Instance = new Instance(warehouse, 1, "server", server, null);
for (var index = 0; index < 2; index++)
{
var admittedSocket = new TestSocket(
IPAddress.Parse($"192.0.2.{20 + index}"),
11100 + index);
var admitted = new EpConnection();
admitted.Assign(admittedSocket);
Assert.True(server.TryAdd(admitted));
admittedSocket.Close();
}
var rejectedSocket = new TestSocket(IPAddress.Parse("192.0.2.22"), 11102);
var rejected = new EpConnection();
rejected.Assign(rejectedSocket);
Assert.False(server.TryAdd(rejected));
Assert.Equal(SocketState.Closed, rejectedSocket.State);
Assert.Empty(server.Connections);
}
[Fact]
public async Task Server_ClosesStalledAuthenticationAtDeadlineAndReleasesSlot()
{
+262
View File
@@ -0,0 +1,262 @@
using Esiur.Core;
using Esiur.Resource;
using Esiur.Stores;
namespace Esiur.Tests.Unit;
public sealed class WarehouseLifecycleTests
{
[Fact]
public async Task Close_AllowsWarehouseToBeOpenedAgain()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
Assert.False(warehouse.IsOpen);
Assert.True(await warehouse.Open());
Assert.True(warehouse.IsOpen);
Assert.False(await warehouse.Open());
Assert.True(await warehouse.Close());
Assert.False(warehouse.IsOpen);
Assert.True(await warehouse.Open());
Assert.True(warehouse.IsOpen);
Assert.True(await warehouse.Close());
Assert.False(warehouse.IsOpen);
}
[Fact]
public async Task Close_WaitsForAnInProgressOpenBeforeTerminatingResources()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var resource = await warehouse.Put("sys/blocking", new BlockingResource());
var open = warehouse.Open();
await resource.InitializeStarted.WaitAsync(TimeSpan.FromSeconds(2));
var close = warehouse.Close();
await Task.Delay(25);
Assert.False(resource.TerminateStarted.IsCompleted);
resource.ReleaseInitialize();
Assert.True(await open);
Assert.True(await close);
Assert.True(resource.TerminateStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
}
[Fact]
public async Task Close_WaitsForEachShutdownPhaseBeforeAdvancingOrReopening()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var terminateReply = new AsyncReply<bool>();
var systemTerminatedReply = new AsyncReply<bool>();
var terminateBlocker = await warehouse.Put(
"sys/terminate-blocker",
new ControlledLifecycleResource(terminateReply: terminateReply));
var systemTerminatedBlocker = await warehouse.Put(
"sys/system-terminated-blocker",
new ControlledLifecycleResource(systemTerminatedReply: systemTerminatedReply));
Assert.True(await warehouse.Open());
var close = Observe(warehouse.Close());
await Task.WhenAll(
terminateBlocker.TerminateStarted,
systemTerminatedBlocker.TerminateStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(terminateBlocker.SystemTerminatedStarted.IsCompleted);
Assert.False(systemTerminatedBlocker.SystemTerminatedStarted.IsCompleted);
terminateReply.Trigger(true);
await Task.WhenAll(
terminateBlocker.SystemTerminatedStarted,
systemTerminatedBlocker.SystemTerminatedStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
Assert.False(close.IsCompleted);
var reopen = Observe(warehouse.Open());
await Task.Delay(25);
Assert.False(reopen.IsCompleted);
systemTerminatedReply.Trigger(true);
Assert.True(await close);
Assert.True(await reopen);
Assert.True(await warehouse.Close());
}
[Fact]
public async Task Close_SynchronousThrowDoesNotSkipOtherResourcesOrSecondPhase()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var expected = new InvalidOperationException("terminate failed synchronously");
var throwing = await warehouse.Put(
"sys/throwing",
new ControlledLifecycleResource(terminateException: expected));
var other = await warehouse.Put(
"sys/other",
new ControlledLifecycleResource());
Assert.True(await warehouse.Open());
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => Observe(warehouse.Close()));
Assert.Same(expected, exception);
Assert.True(other.TerminateStarted.IsCompletedSuccessfully);
Assert.True(throwing.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.True(other.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
Assert.False(await warehouse.Close());
}
[Fact]
public async Task Close_AsyncErrorWaitsForEveryOtherReplyToSettle()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var failingReply = new AsyncReply<bool>();
var blockingReply = new AsyncReply<bool>();
var failing = await warehouse.Put(
"sys/failing",
new ControlledLifecycleResource(systemTerminatedReply: failingReply));
var blocking = await warehouse.Put(
"sys/blocking",
new ControlledLifecycleResource(systemTerminatedReply: blockingReply));
Assert.True(await warehouse.Open());
var close = Observe(warehouse.Close());
await Task.WhenAll(failing.SystemTerminatedStarted, blocking.SystemTerminatedStarted)
.WaitAsync(TimeSpan.FromSeconds(2));
var expected = new InvalidOperationException("system termination failed asynchronously");
failingReply.TriggerError(expected);
Assert.False(close.IsCompleted);
blockingReply.Trigger(true);
var exception = await Assert.ThrowsAsync<AsyncException>(() => close);
Assert.Same(expected, exception.InnerException);
Assert.True(failing.TerminateStarted.IsCompletedSuccessfully);
Assert.True(blocking.TerminateStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
Assert.False(await warehouse.Close());
}
[Fact]
public async Task Close_ReturnsFalseAfterBothPhasesWhenAResourceReturnsFalse()
{
var warehouse = new Warehouse();
await warehouse.Put("sys", new MemoryStore());
var resource = await warehouse.Put(
"sys/false-result",
new ControlledLifecycleResource(
terminateReply: new AsyncReply<bool>(false)));
Assert.True(await warehouse.Open());
Assert.False(await warehouse.Close());
Assert.True(resource.SystemTerminatedStarted.IsCompletedSuccessfully);
Assert.False(warehouse.IsOpen);
}
private static async Task<bool> Observe(AsyncReply<bool> reply) => await reply;
private sealed class ControlledLifecycleResource : IResource
{
private readonly AsyncReply<bool>? terminateReply;
private readonly AsyncReply<bool>? systemTerminatedReply;
private readonly Exception? terminateException;
private readonly TaskCompletionSource terminateStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource systemTerminatedStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public ControlledLifecycleResource(
AsyncReply<bool>? terminateReply = null,
AsyncReply<bool>? systemTerminatedReply = null,
Exception? terminateException = null)
{
this.terminateReply = terminateReply;
this.systemTerminatedReply = systemTerminatedReply;
this.terminateException = terminateException;
}
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task TerminateStarted => terminateStarted.Task;
public Task SystemTerminatedStarted => systemTerminatedStarted.Task;
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Terminate)
{
terminateStarted.TrySetResult();
if (terminateException != null)
throw terminateException;
return terminateReply ?? new AsyncReply<bool>(true);
}
if (operation == ResourceOperation.SystemTerminated)
{
systemTerminatedStarted.TrySetResult();
return systemTerminatedReply ?? new AsyncReply<bool>(true);
}
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
private sealed class BlockingResource : IResource
{
private readonly AsyncReply<bool> initialize = new();
private readonly TaskCompletionSource initializeStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource terminateStarted = new(
TaskCreationOptions.RunContinuationsAsynchronously);
public event DestroyedEvent? OnDestroy;
public Instance? Instance { get; set; }
public Task InitializeStarted => initializeStarted.Task;
public Task TerminateStarted => terminateStarted.Task;
public void ReleaseInitialize() => initialize.Trigger(true);
public AsyncReply<bool> Handle(
ResourceOperation operation,
IResourceContext? context = null)
{
if (operation == ResourceOperation.Initialize)
{
initializeStarted.TrySetResult();
return initialize;
}
if (operation == ResourceOperation.Terminate)
terminateStarted.TrySetResult();
return new AsyncReply<bool>(true);
}
public void Destroy() => OnDestroy?.Invoke(this);
}
}