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; /// /// 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)); } /// /// 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"; }