Files
DodoSSH/tests/DodoSSH.SystemTests/DevStack.cs
T
jaap-jan ea271d980a Give the realm's users their roles, and sign in as one in the E2E suite
Signing in failed at the token exchange with `400 Offline tokens not allowed
for the user or client`. A user declared in a realm import gets no role
mappings at all unless realmRoles lists them — not even the realm's own
default-roles composite, which Keycloak grants automatically to a user created
through the admin API or the registration form. alice and bob had none, and
offline_access lives inside that composite, which the desktop client requests.
Verified against the running Keycloak: alice's role-mappings were {} before and
resolve to default-roles-dodossh, offline_access, uma_authorization after.

The authorization request succeeds and the failure lands one step later, at the
code redemption, which makes it read like a client bug. It is not.

The E2E suite could not catch this because it created its own account through
the admin API — exercising a provisioning path no real user takes, and passing
while the account the README tells you to use could not sign in at all. It now
signs in as the realm's own alice, which is sound because the Keycloak and
PostgreSQL containers are per-run so the account is pristine, and this assembly
holds one test. Removing the roles again fails it with exactly the reported
message; that is what makes the coverage real rather than nominal.

Two traps recorded in docs/platform-flags.md, the second found by shipping it
for a moment: Keycloak's RealmRepresentation deserialises with
FAIL_ON_UNKNOWN_PROPERTIES enabled, so the "_comment" key I first used to
explain the roles inside the JSON did not get ignored — the import threw and
the container refused to start. Explanations go in the docs, not in the realm
file.
2026-07-29 14:11:16 +02:00

216 lines
10 KiB
C#

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;
/// <summary>
/// The whole stack the end-to-end suite talks to, brought up per run and thrown away after.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// It deliberately consumes the artefacts that ship — the realm file from <c>deploy/keycloak</c>, the
/// committed EF migrations, the API's own <c>appsettings</c> — 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.
/// </para>
/// <para>
/// One thing cannot come from a committed file: the identity provider's port is assigned when its container
/// starts, so <c>Oidc:Authority</c> 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.
/// </para>
/// <para>
/// A fresh Keycloak per run also removes a footgun the development stack has: <c>--import-realm</c> 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.
/// </para>
/// </remarks>
public sealed class DevStack : IAsyncLifetime
{
/// <summary>The realm the committed file defines.</summary>
internal const string Realm = "dodossh";
/// <summary>The account the OpenSSH container is built around.</summary>
internal const string SshUsername = "dodo";
/// <summary>Its password. A container that lives for one test run, on a random loopback port.</summary>
internal const string SshPassword = "correct-horse-battery-staple";
/// <remarks>
/// Pinned to the same version as <c>deploy/docker-compose.dev.yml</c>. 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.
/// </remarks>
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;
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>Where the API is listening, which is the only URL the client is told.</summary>
internal Uri ApiBaseUrl => Api.BaseUrl;
/// <summary>The identity provider's base URL, port included.</summary>
internal Uri KeycloakBaseUrl => new(string.Create(
CultureInfo.InvariantCulture,
$"http://{keycloak.Hostname}:{keycloak.GetMappedPublicPort(KeycloakPort)}"));
/// <summary>The issuer the API is configured to trust.</summary>
internal Uri Authority => new(KeycloakBaseUrl, $"/realms/{Realm}");
/// <summary>Where the OpenSSH container answers.</summary>
internal string SshHostname => sshd.Hostname;
/// <summary>Its mapped port.</summary>
internal int SshHostPort => sshd.GetMappedPublicPort(SshPort);
/// <summary>The API's console output, for explaining a failure.</summary>
internal string ApiLog => Api.Log;
private ApiProcess Api => api
?? throw new InvalidOperationException("The stack has not been started.");
/// <inheritdoc />
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);
}
/// <inheritdoc />
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());
}
/// <summary>
/// The account the committed realm file declares, which is the one the README tells a user to sign in
/// as.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>default-roles-&lt;realm&gt;</c> 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 <c>realmRoles</c>. So the suite exercised a provisioning path no real user
/// takes, and passed while the realm's own <c>alice</c> could not complete a sign-in — the token
/// endpoint answered <em>"Offline tokens not allowed for the user or client"</em>, because
/// <c>offline_access</c> lives inside that composite and the desktop client asks for it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static DevStackUser RealmUser { get; } = new("alice", "alice");
/// <remarks>
/// Through the same design-time factory <c>dotnet ef database update</c> 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 <see cref="ApiProcess"/> would say so — the schema is verified by the
/// server's own opinion of it rather than by this fixture's.
/// </remarks>
private async Task MigrateAsync(CancellationToken cancellationToken)
{
var context = DodoDbContextFactory.Create(postgres.GetConnectionString());
await using var scope = context.ConfigureAwait(false);
await context.Database.MigrateAsync(cancellationToken);
}
}
/// <summary>Credentials for one identity in the development realm.</summary>
internal sealed record DevStackUser(string Username, string Password);