Public Access
255 lines
10 KiB
C#
255 lines
10 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;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
|
|
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);
|
|
services.AddSingleton<IInterceptor>(Commits);
|
|
});
|
|
}
|
|
|
|
/// <summary>Fails a commit on request, so a test can watch the retrying execution strategy at work.</summary>
|
|
internal FlakyCommits Commits { get; } = new();
|
|
|
|
/// <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";
|
|
}
|