Public Access
Send the event socket's close code instead of aborting the socket
The events endpoint cancelled its pump token and only then wrote the close frame. The token was also the one passed to socket.ReceiveAsync and SendAsync, and cancelling a pending operation on the ManagedWebSocket that Kestrel hands over aborts it. CloseAsync then saw an Aborted socket and returned, so in production a client whose token expired got a TCP reset rather than 4401 "reconnect with a fresh token", and a shutdown sent no 1001 either. The tests missed it because TestServer's in-memory socket survives that cancellation: the same scenario through TestServer closes with 4401. PumpAsync now does what the relay's pump does. No WebSocket operation is cancelled; the token stops only the outbound queue, the clock and the access refresh. The sender is let finish its last write, the close goes out, and the receiver ends on the client's answering close. Each wait is bounded by the close timeout, and the socket is aborted only when a peer stops answering. ApiFixture gains ConnectEventsOverTcpAsync, which runs a second host on real Kestrel over loopback, sharing the container and identity stub. The new AnExpiringToken_ClosesTheSocketWith4401 fails with a reset before this change and passes after it.
This commit is contained in:
1 parent
ab432e475e
commit
c8848ec1e3
3 files changed
+183
-19
No files matched your search
@@ -1,9 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.WebSockets;
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -29,6 +32,13 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
.WithPassword("test")
|
||||
.Build();
|
||||
|
||||
private readonly Lazy<(WebApplicationFactory<Program> Host, Uri Address)> kestrel;
|
||||
|
||||
public ApiFixture()
|
||||
{
|
||||
kestrel = new Lazy<(WebApplicationFactory<Program>, Uri)>(StartKestrel);
|
||||
}
|
||||
|
||||
/// <summary>The stubbed identity provider.</summary>
|
||||
public StubIdentityProvider IdentityProvider { get; } = new();
|
||||
|
||||
@@ -45,6 +55,11 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (kestrel.IsValueCreated)
|
||||
{
|
||||
await kestrel.Value.Host.DisposeAsync();
|
||||
}
|
||||
|
||||
await base.DisposeAsync();
|
||||
await container.DisposeAsync();
|
||||
IdentityProvider.Dispose();
|
||||
@@ -162,6 +177,64 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
new Uri(Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the event socket with the supplied raw token, over real Kestrel and a real TCP connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For what <c>TestServer</c> cannot show: how the socket behaves when it ends. Kestrel hands the
|
||||
/// endpoint a <c>ManagedWebSocket</c>, which aborts itself when a pending operation is cancelled,
|
||||
/// and an aborted socket cannot send the close frame that tells a client why it was dropped. The
|
||||
/// in-memory socket <c>TestServer</c> provides does not reproduce that, so a close-code test run
|
||||
/// through it can pass against a server that never sends one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second host on the same database, started on first use. It listens on loopback only, for the
|
||||
/// same Windows Firewall reason as <see cref="StubIdentityProvider"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<WebSocket> ConnectEventsOverTcpAsync(string token, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new ClientWebSocket();
|
||||
|
||||
try
|
||||
{
|
||||
socket.Options.AddSubProtocol(VaultEvents.SubProtocol);
|
||||
socket.Options.SetRequestHeader("Authorization", $"Bearer {token}");
|
||||
|
||||
var address = new UriBuilder(new Uri(kestrel.Value.Address, VaultEvents.Path.TrimStart('/')))
|
||||
{
|
||||
Scheme = Uri.UriSchemeWs,
|
||||
};
|
||||
|
||||
await socket.ConnectAsync(address.Uri, cancellationToken);
|
||||
|
||||
return socket;
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private (WebApplicationFactory<Program> Host, Uri Address) StartKestrel()
|
||||
{
|
||||
// WithWebHostBuilder rather than a second fixture class, so the host inherits ConfigureWebHost
|
||||
// above and therefore this container, this identity provider and this signing key.
|
||||
var host = WithWebHostBuilder(_ => { });
|
||||
|
||||
host.UseKestrel(options => options.Listen(IPAddress.Loopback, 0));
|
||||
host.StartServer();
|
||||
|
||||
var address = host.Services
|
||||
.GetRequiredService<IServer>()
|
||||
.Features.Get<IServerAddressesFeature>()!
|
||||
.Addresses.Single();
|
||||
|
||||
return (host, new Uri(address));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shares one host and container across every test class in the assembly.</summary>
|
||||
|
||||
Reference in new issue
Block a user