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);