Public Access
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.
247 lines
9.9 KiB
C#
247 lines
9.9 KiB
C#
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;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Testcontainers.PostgreSql;
|
|
using Xunit;
|
|
|
|
namespace DodoSSH.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// Hosts the API in-process against a real PostgreSQL container and a stubbed identity provider.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One container and one host per assembly. Tests therefore use distinct users and vaults rather
|
|
/// than assuming an empty database.
|
|
/// </remarks>
|
|
public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
|
{
|
|
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine")
|
|
.WithDatabase("dodossh")
|
|
.WithUsername("postgres")
|
|
.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();
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
await container.StartAsync();
|
|
|
|
await using var scope = Services.CreateAsyncScope();
|
|
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
|
await database.Database.MigrateAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async ValueTask DisposeAsync()
|
|
{
|
|
if (kestrel.IsValueCreated)
|
|
{
|
|
await kestrel.Value.Host.DisposeAsync();
|
|
}
|
|
|
|
await base.DisposeAsync();
|
|
await container.DisposeAsync();
|
|
IdentityProvider.Dispose();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.UseEnvironment("Testing");
|
|
|
|
// Environment variables rather than ConfigureAppConfiguration. Under minimal hosting the
|
|
// application's own configuration sources are added inside Program.cs after this callback
|
|
// runs, so an appsettings.json value would win over anything registered here. Program.cs
|
|
// adds AddEnvironmentVariables("DODOSSH_") last, which makes these authoritative.
|
|
var settings = new Dictionary<string, string?>(StringComparer.Ordinal)
|
|
{
|
|
["DODOSSH_ConnectionStrings__Postgres"] = container.GetConnectionString(),
|
|
["DODOSSH_Server__PublicBaseUrl"] = "http://localhost",
|
|
["DODOSSH_Oidc__Authority"] = IdentityProvider.Authority,
|
|
["DODOSSH_Oidc__Audience"] = StubIdentityProvider.Audience,
|
|
["DODOSSH_Oidc__ClientId"] = "dodossh-desktop",
|
|
|
|
// The stub serves plaintext HTTP on a loopback port.
|
|
["DODOSSH_Oidc__RequireHttpsMetadata"] = "false",
|
|
|
|
// On, so the relay's endpoints can be tested at all. Nothing dials a real network: the seam
|
|
// below answers every resolve and connect, and the address policy still judges what it says.
|
|
["DODOSSH_Relay__Enabled"] = "true",
|
|
["DODOSSH_Relay__WebSocketUrl"] = "ws://localhost" + RelayProtocol.ConnectPath,
|
|
|
|
// Small, so a test can reach the cap with two sockets rather than ten.
|
|
["DODOSSH_Relay__MaxConcurrentSessionsPerUser"] = "2",
|
|
|
|
// Short enough that a test can watch a session time out, long enough that no test which is
|
|
// moving bytes comes near it.
|
|
["DODOSSH_Relay__IdleTimeout"] = "00:00:03",
|
|
|
|
// Fixed so cursors stay valid for the lifetime of the test host.
|
|
["DODOSSH_Sync__CursorSigningKey"] = Convert.ToBase64String(new byte[32]),
|
|
};
|
|
|
|
foreach (var (name, value) in settings)
|
|
{
|
|
Environment.SetEnvironmentVariable(name, value);
|
|
}
|
|
|
|
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every address a test machine can reach by itself is loopback, which the relay refuses outright, so
|
|
/// a relay test needs a network that pretends. This one maps the addresses a test names onto loopback
|
|
/// listeners the test owns, and remembers what it was asked to dial.
|
|
/// </remarks>
|
|
internal FakeRelayNetwork RelayNetwork { get; } = new();
|
|
|
|
/// <summary>Creates a client carrying a valid token for the given subject.</summary>
|
|
/// <remarks>
|
|
/// There is no verified-address knob here, and its absence is worth a sentence. It used to exist so
|
|
/// a test could present the one shape the server refused — an address the provider had not vouched
|
|
/// for, offered against a pending team invitation. Nothing in the server reads
|
|
/// <c>email_verified</c> now, because nothing decides access from an address at all, so a parameter
|
|
/// here would be one that changes no outcome.
|
|
/// </remarks>
|
|
public HttpClient CreateClientFor(string subject, string? email = null)
|
|
{
|
|
var client = CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
|
|
"Bearer",
|
|
IdentityProvider.MintToken(subject, email));
|
|
|
|
return client;
|
|
}
|
|
|
|
/// <summary>Creates a client carrying the supplied raw token.</summary>
|
|
public HttpClient CreateClientWithToken(string token)
|
|
{
|
|
var client = CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
return client;
|
|
}
|
|
|
|
/// <summary>Opens a database scope for arranging state and asserting on it.</summary>
|
|
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
|
|
|
|
/// <summary>
|
|
/// Opens the event socket as the given subject, through the real pipeline.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The bearer token goes on the upgrade request, which is the whole of the socket's authorization
|
|
/// — see ADR 0012 — so a test that stubbed it would be testing nothing. The subprotocol is offered
|
|
/// because the server refuses an upgrade that does not, and that refusal is itself under test.
|
|
/// </para>
|
|
/// <para>
|
|
/// <c>TestServer</c> speaks WebSockets in-memory with no port and no network, so these run
|
|
/// wherever the rest of the suite does.
|
|
/// </para>
|
|
/// </remarks>
|
|
public Task<WebSocket> ConnectEventsAsync(string subject, CancellationToken cancellationToken)
|
|
{
|
|
var token = IdentityProvider.MintToken(subject);
|
|
var client = Server.CreateWebSocketClient();
|
|
|
|
client.SubProtocols.Add(VaultEvents.SubProtocol);
|
|
// The server-side request, so the header is a raw string rather than a typed value.
|
|
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {token}";
|
|
|
|
return client.ConnectAsync(
|
|
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>
|
|
[CollectionDefinition(Name)]
|
|
public sealed class ApiCollection : ICollectionFixture<ApiFixture>
|
|
{
|
|
/// <summary>Collection name.</summary>
|
|
public const string Name = "api";
|
|
}
|