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;
///
/// Hosts the API in-process against a real PostgreSQL container and a stubbed identity provider.
///
///
/// One container and one host per assembly. Tests therefore use distinct users and vaults rather
/// than assuming an empty database.
///
public sealed class ApiFixture : WebApplicationFactory, IAsyncLifetime
{
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:18-alpine")
.WithDatabase("dodossh")
.WithUsername("postgres")
.WithPassword("test")
.Build();
private readonly Lazy<(WebApplicationFactory Host, Uri Address)> kestrel;
public ApiFixture()
{
kestrel = new Lazy<(WebApplicationFactory, Uri)>(StartKestrel);
}
/// The stubbed identity provider.
public StubIdentityProvider IdentityProvider { get; } = new();
///
public async ValueTask InitializeAsync()
{
await container.StartAsync();
await using var scope = Services.CreateAsyncScope();
var database = scope.ServiceProvider.GetRequiredService();
await database.Database.MigrateAsync();
}
///
public override async ValueTask DisposeAsync()
{
if (kestrel.IsValueCreated)
{
await kestrel.Value.Host.DisposeAsync();
}
await base.DisposeAsync();
await container.DisposeAsync();
IdentityProvider.Dispose();
}
///
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(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(RelayNetwork);
services.AddSingleton(Commits);
});
}
/// Fails a commit on request, so a test can watch the retrying execution strategy at work.
internal FlakyCommits Commits { get; } = new();
///
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
///
///
/// 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.
///
internal FakeRelayNetwork RelayNetwork { get; } = new();
/// Creates a client carrying a valid token for the given subject.
///
/// 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
/// email_verified now, because nothing decides access from an address at all, so a parameter
/// here would be one that changes no outcome.
///
public HttpClient CreateClientFor(string subject, string? email = null)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
IdentityProvider.MintToken(subject, email));
return client;
}
/// Creates a client carrying the supplied raw token.
public HttpClient CreateClientWithToken(string token)
{
var client = CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
/// Opens a database scope for arranging state and asserting on it.
public AsyncServiceScope CreateScope() => Services.CreateAsyncScope();
///
/// Opens the event socket as the given subject, through the real pipeline.
///
///
///
/// 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.
///
///
/// TestServer speaks WebSockets in-memory with no port and no network, so these run
/// wherever the rest of the suite does.
///
///
public Task 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);
}
///
/// Opens the event socket with the supplied raw token, over real Kestrel and a real TCP connection.
///
///
///
/// For what TestServer cannot show: how the socket behaves when it ends. Kestrel hands the
/// endpoint a ManagedWebSocket, 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 TestServer provides does not reproduce that, so a close-code test run
/// through it can pass against a server that never sends one.
///
///
/// A second host on the same database, started on first use. It listens on loopback only, for the
/// same Windows Firewall reason as .
///
///
public async Task 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 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()
.Features.Get()!
.Addresses.Single();
return (host, new Uri(address));
}
}
/// Shares one host and container across every test class in the assembly.
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture
{
/// Collection name.
public const string Name = "api";
}