mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
850 lines
32 KiB
C#
850 lines
32 KiB
C#
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);
|
|
}
|
|
}
|