using System.Globalization; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; using DodoSSH.Infrastructure; using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Microsoft.EntityFrameworkCore; using Testcontainers.PostgreSql; namespace DodoSSH.SystemTests; /// /// The whole stack the end-to-end suite talks to, brought up per run and thrown away after. /// /// /// /// PostgreSQL, Keycloak and an OpenSSH server in containers; the API as a child process out of its own /// build output. Nothing is stubbed and nothing has to be started by hand, which is what allows this suite /// to run in CI rather than only on the machine of whoever remembered the commands. /// /// /// It deliberately consumes the artefacts that ship — the realm file from deploy/keycloak, the /// committed EF migrations, the API's own appsettings — rather than an arrangement written to match /// them. That distinction is the whole value of the suite: a fixture that restates the configuration agrees /// with itself by construction, and both bugs this suite has found were in the committed configuration, /// invisible to every other test. /// /// /// One thing cannot come from a committed file: the identity provider's port is assigned when its container /// starts, so Oidc:Authority is overridden to match. Everything the authority points at — the realm, /// the public client, the loopback redirect URI, the audience mapper — is still the real file, and the URL /// is the one value a self-hosted deployment is guaranteed to change anyway. /// /// /// A fresh Keycloak per run also removes a footgun the development stack has: --import-realm skips a /// realm that already exists, so editing the realm file and restarting the container serves the old /// configuration and looks exactly like the edit being wrong. /// /// public sealed class DevStack : IAsyncLifetime { /// The realm the committed file defines. internal const string Realm = "dodossh"; /// The account the OpenSSH container is built around. internal const string SshUsername = "dodo"; /// Its password. A container that lives for one test run, on a random loopback port. internal const string SshPassword = "correct-horse-battery-staple"; /// /// Pinned to the same version as deploy/docker-compose.dev.yml. Two places, deliberately: the /// suite cannot read the compose file without becoming a compose parser, and a version skew between /// them shows up here as a failure rather than as a difference nobody notices. /// private const string KeycloakImage = "quay.io/keycloak/keycloak:26.4"; private const string PostgresImage = "postgres:18-alpine"; private const string SshImage = "linuxserver/openssh-server:latest"; private const ushort KeycloakPort = 8080; private const ushort SshPort = 2222; /// /// Generous, and covering image pulls on a cold machine. It exists so that a daemon that never answers /// fails with a sentence rather than hanging until the test host is killed. /// private static readonly TimeSpan StartupTimeout = TimeSpan.FromMinutes(8); private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder(PostgresImage) .WithDatabase("dodossh") .WithUsername("dodossh") .WithPassword("dodossh") // Matching the compose stack: deterministic collation, as the server's InvariantGlobalization // implies. Ordering differences between a C locale and a language-aware one are exactly the kind // of thing that behaves in development and not in production. .WithEnvironment("POSTGRES_INITDB_ARGS", "--encoding=UTF8 --locale=C") .Build(); private readonly IContainer keycloak = new ContainerBuilder(KeycloakImage) // start-dev, as the development stack does: no HTTPS enforcement and throwaway H2 state. The realm // is imported from the committed file, which is the artefact under test here. .WithCommand("start-dev", "--import-realm") .WithEnvironment("KC_BOOTSTRAP_ADMIN_USERNAME", "admin") .WithEnvironment("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin") .WithPortBinding(KeycloakPort, assignRandomHostPort: true) .WithResourceMapping( new FileInfo(Path.Combine(AppContext.BaseDirectory, "realm-dodossh.json")), "/opt/keycloak/data/import/") // Serving the realm's discovery document is the only readiness that means anything: the port opens // well before the import finishes, and an unimported realm answers 404 to every request the client // is about to make. .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(request => request .ForPort(KeycloakPort) .ForPath($"/realms/{Realm}/.well-known/openid-configuration"))) .Build(); private readonly IContainer sshd = new ContainerBuilder(SshImage) .WithEnvironment("PUID", "1000") .WithEnvironment("PGID", "1000") .WithEnvironment("USER_NAME", SshUsername) .WithEnvironment("USER_PASSWORD", SshPassword) .WithEnvironment("PASSWORD_ACCESS", "true") .WithEnvironment("SUDO_ACCESS", "false") .WithPortBinding(SshPort, assignRandomHostPort: true) // A published port is not readiness: the entrypoint generates host keys and rewrites sshd_config // first. This is the check that actually proves sshd is accepting. .WithWaitStrategy(Wait.ForUnixContainer() .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'")) .Build(); private ApiProcess? api; /// Where the API is listening, which is the only URL the client is told. internal Uri ApiBaseUrl => Api.BaseUrl; /// The identity provider's base URL, port included. internal Uri KeycloakBaseUrl => new(string.Create( CultureInfo.InvariantCulture, $"http://{keycloak.Hostname}:{keycloak.GetMappedPublicPort(KeycloakPort)}")); /// The issuer the API is configured to trust. internal Uri Authority => new(KeycloakBaseUrl, $"/realms/{Realm}"); /// Where the OpenSSH container answers. internal string SshHostname => sshd.Hostname; /// Its mapped port. internal int SshHostPort => sshd.GetMappedPublicPort(SshPort); /// The API's console output, for explaining a failure. internal string ApiLog => Api.Log; private ApiProcess Api => api ?? throw new InvalidOperationException("The stack has not been started."); /// public async ValueTask InitializeAsync() { using var deadline = new CancellationTokenSource(StartupTimeout); // Concurrently: none of the three depends on another, and Keycloak alone takes longer than the // other two together. await Task.WhenAll( postgres.StartAsync(deadline.Token), keycloak.StartAsync(deadline.Token), sshd.StartAsync(deadline.Token)); await MigrateAsync(deadline.Token); api = await ApiProcess.StartAsync(postgres.GetConnectionString(), Authority, deadline.Token); } /// public async ValueTask DisposeAsync() { // The API first: it holds connections to PostgreSQL, and tearing the database out from under it // produces a page of exceptions that look like a failure rather than a shutdown. if (api is not null) { await api.DisposeAsync(); } await Task.WhenAll( postgres.DisposeAsync().AsTask(), keycloak.DisposeAsync().AsTask(), sshd.DisposeAsync().AsTask()); } /// /// Creates a Keycloak user this test run owns. /// /// /// A fresh account rather than the realm's alice. Enrollment happens once per account and cannot /// be undone from the client, so an account shared between tests would mean the second one exercises a /// different path from the first and neither could assert an exact vault state. The realm is thrown /// away with the container, so this only has to be unique within a run — but making it unique per call /// is what keeps a second test from silently depending on the first. /// internal async Task CreateUserAsync(CancellationToken cancellationToken) { var username = string.Create(CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}"); const string Password = "e2e-password"; using var http = new HttpClient { BaseAddress = KeycloakBaseUrl }; var token = await AdminTokenAsync(http, cancellationToken); http.DefaultRequestHeaders.Authorization = new("Bearer", token); var user = new JsonObject { ["username"] = username, ["email"] = $"{username}@example.test", ["firstName"] = "End", ["lastName"] = "ToEnd", ["enabled"] = true, ["emailVerified"] = true, ["credentials"] = new JsonArray { new JsonObject { ["type"] = "password", ["value"] = Password, ["temporary"] = false, }, }, }; using var response = await http.PostAsJsonAsync( $"/admin/realms/{Realm}/users", user, cancellationToken); response.EnsureSuccessStatusCode(); return new DevStackUser(username, Password); } /// /// Through the same design-time factory dotnet ef database update uses, so the migrations and /// the history table land exactly where the API expects them. If they did not, the API's readiness /// check would refuse to pass and would say so — the schema is verified by the /// server's own opinion of it rather than by this fixture's. /// private async Task MigrateAsync(CancellationToken cancellationToken) { var context = DodoDbContextFactory.Create(postgres.GetConnectionString()); await using var scope = context.ConfigureAwait(false); await context.Database.MigrateAsync(cancellationToken); } private static async Task AdminTokenAsync(HttpClient http, CancellationToken cancellationToken) { using var form = new FormUrlEncodedContent( new Dictionary(StringComparer.Ordinal) { ["client_id"] = "admin-cli", ["username"] = "admin", ["password"] = "admin", ["grant_type"] = "password", }); using var response = await http.PostAsync( "/realms/master/protocol/openid-connect/token", form, cancellationToken); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(cancellationToken); return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("Keycloak returned no admin access token."); } } /// An account created for one test run. internal sealed record DevStackUser(string Username, string Password);