using System.Net.Http.Headers; using System.Net.WebSockets; using DodoSSH.Contracts; using DodoSSH.Infrastructure; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; 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(); /// 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() { 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", ["DODOSSH_Relay__Enabled"] = "false", // 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); } } /// 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); } } /// 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"; }