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>
|
||||
|
||||
@@ -304,6 +304,31 @@ public sealed class EventsEndpointTests(ApiFixture fixture)
|
||||
socket.State.ShouldBe(WebSocketState.Open);
|
||||
}
|
||||
|
||||
// ---- The close ----
|
||||
|
||||
[Fact]
|
||||
public async Task AnExpiringToken_ClosesTheSocketWith4401()
|
||||
{
|
||||
// The code is the whole message: 4401 tells a client to reconnect at once with a fresh token,
|
||||
// where anything else — or a connection that simply drops — tells it to back off. Over real
|
||||
// Kestrel, because TestServer's in-memory socket survived the server cancelling its own pending
|
||||
// receive, which is what hid a server that aborted the socket instead of closing it.
|
||||
var (subject, _) = await SeedUserWithVaultAsync();
|
||||
|
||||
// Seconds, not minutes: the bearer handler's clock skew lets a token this close to expiry
|
||||
// through the upgrade, and the socket's own deadline is the token's exp without skew.
|
||||
var token = fixture.IdentityProvider.MintToken(
|
||||
subject, expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddSeconds(2));
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await fixture.ConnectEventsOverTcpAsync(token, timeout.Token);
|
||||
|
||||
var closed = await ReadCloseAsync(socket, timeout.Token);
|
||||
|
||||
((int?)closed.CloseStatus).ShouldBe(VaultEvents.TokenExpiredCloseCode);
|
||||
closed.CloseStatusDescription.ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
|
||||
@@ -340,6 +365,31 @@ public sealed class EventsEndpointTests(ApiFixture fixture)
|
||||
return Encoding.UTF8.GetString(buffer, 0, received.Count);
|
||||
}
|
||||
|
||||
/// <summary>Reads past every frame to the server's close, and answers it.</summary>
|
||||
/// <remarks>
|
||||
/// A socket the server aborted rather than closed throws here instead of returning, which is the
|
||||
/// failure this exists to catch.
|
||||
/// </remarks>
|
||||
private static async Task<WebSocketReceiveResult> ReadCloseAsync(
|
||||
WebSocket socket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[8 * 1024];
|
||||
|
||||
while (true)
|
||||
{
|
||||
var received = await socket.ReceiveAsync(buffer, cancellationToken);
|
||||
|
||||
if (received.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await socket.CloseOutputAsync(
|
||||
WebSocketCloseStatus.NormalClosure, string.Empty, cancellationToken);
|
||||
|
||||
return received;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
|
||||
private static async Task<VaultEvent> ReadUntilAsync(
|
||||
WebSocket socket,
|
||||
|
||||
Reference in new issue
Block a user