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());
}
///
/// The account the committed realm file declares, which is the one the README tells a user to sign in
/// as.
///
///
///
/// This suite used to create its own account through Keycloak's admin API instead, and that was a hole
/// rather than a detail: Keycloak grants default-roles-<realm> automatically to a user
/// created through the admin API, and grants nothing at all to a user declared in a realm import
/// without an explicit realmRoles. So the suite exercised a provisioning path no real user
/// takes, and passed while the realm's own alice could not complete a sign-in — the token
/// endpoint answered "Offline tokens not allowed for the user or client", because
/// offline_access lives inside that composite and the desktop client asks for it.
///
///
/// Using the realm's account closes that, at the cost of one constraint: enrollment happens once per
/// account and cannot be undone from the client, so this only works because the Keycloak and PostgreSQL
/// containers are both per-run and this assembly holds one test. A second test needing a second
/// identity must declare it in the realm file — with its roles — rather than mint one at runtime, or it
/// reopens exactly this hole.
///
///
internal static DevStackUser RealmUser { get; } = new("alice", "alice");
///
/// 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);
}
}
/// Credentials for one identity in the development realm.
internal sealed record DevStackUser(string Username, string Password);