Public Access
Make the end-to-end suite self-contained with Testcontainers
It needed a hand-started stack and an opt-in flag, so it ran on one machine and never in CI. It now brings up PostgreSQL, Keycloak and an OpenSSH server itself, applies the committed migrations and starts the API as a child process, which makes it part of the ordinary test run at ~25s. The API runs as a process rather than through WebApplicationFactory. The client builds its own HttpClient for a URL the user typed, so there is no seam to hand a test handler through without inventing one that exists only for tests — and a test host would replace the entry point, Kestrel and the content root, so it would never prove that Program.cs composes or that the committed appsettings is found and layered in the documented order. Running out of the API's own output directory is what makes its configuration real. The suite still consumes what ships: the realm file from deploy/keycloak, the EF migrations, the API's own appsettings. Only Oidc:Authority is overridden, because the container's port is assigned at start. Falsified by reintroducing the wildcard-port redirect URI the realm once had — Keycloak rejects the authorization request and the suite fails at sign-in, which is what proves the committed file is the one imported. Skipping the migration step likewise fails, and the failure names the pending migration. A fresh Keycloak per run also sidesteps the --import-realm trap: editing the realm file and rerunning now always tests the edit. DodoDbContextFactory gains a Create(connectionString) so the fixture and dotnet ef place the migrations history table in exactly one place. If they disagreed the API would report every migration pending, which is how the readiness gate catches it.
This commit is contained in:
@@ -2,85 +2,194 @@ 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 development stack this suite talks to, and how to find out whether it is there.
|
||||
/// The whole stack the end-to-end suite talks to, brought up per run and thrown away after.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately targets a stack the developer brought up rather than starting its own containers. That
|
||||
/// makes the suite test the configuration that is actually committed — the realm file, the API's
|
||||
/// appsettings, the migration history — instead of a parallel arrangement assembled for the test, which is
|
||||
/// where a divergence between "works in the suite" and "works when you run it" comes from.
|
||||
/// <para>
|
||||
/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start.
|
||||
/// 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>
|
||||
internal static class DevStack
|
||||
public sealed class DevStack : IAsyncLifetime
|
||||
{
|
||||
internal const string ApiBaseUrl = "http://localhost:5233";
|
||||
internal const string KeycloakBaseUrl = "http://localhost:18080";
|
||||
/// <summary>The realm the committed file defines.</summary>
|
||||
internal const string Realm = "dodossh";
|
||||
|
||||
private const string OptInVariable = "DODOSSH_E2E";
|
||||
/// <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";
|
||||
|
||||
/// <summary>
|
||||
/// Explains why the suite cannot run, or returns null when it can.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A message rather than a boolean, because a skipped end-to-end suite that does not say what is
|
||||
/// missing is a suite nobody ever runs again.
|
||||
/// 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>
|
||||
internal static async Task<string?> WhyUnavailableAsync(CancellationToken cancellationToken)
|
||||
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()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable(OptInVariable) is not "1")
|
||||
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)
|
||||
{
|
||||
return $"Set {OptInVariable}=1 to run the end-to-end suite. It needs the development stack:"
|
||||
+ "\n docker compose -f deploy/docker-compose.dev.yml up -d"
|
||||
+ "\n dotnet ef database update --project src/DodoSSH.Infrastructure"
|
||||
+ "\n dotnet run --project src/DodoSSH.Api";
|
||||
await api.DisposeAsync();
|
||||
}
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
if (!await RespondsAsync(http, $"{ApiBaseUrl}/healthz/ready", cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return $"The API is not ready at {ApiBaseUrl}. Start it with "
|
||||
+ "`dotnet run --project src/DodoSSH.Api`, and check that migrations have been applied — "
|
||||
+ "readiness fails while any are pending, by design.";
|
||||
}
|
||||
|
||||
var discovery = $"{KeycloakBaseUrl}/realms/{Realm}/.well-known/openid-configuration";
|
||||
|
||||
if (!await RespondsAsync(http, discovery, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return $"Keycloak is not serving the '{Realm}' realm at {KeycloakBaseUrl}. Bring it up with "
|
||||
+ "`docker compose -f deploy/docker-compose.dev.yml up -d`.";
|
||||
}
|
||||
|
||||
return null;
|
||||
await Task.WhenAll(
|
||||
postgres.DisposeAsync().AsTask(),
|
||||
keycloak.DisposeAsync().AsTask(),
|
||||
sshd.DisposeAsync().AsTask());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Keycloak user this test run owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fresh account per run, rather than the realm's <c>alice</c>. Enrollment happens once per account
|
||||
/// and cannot be undone from the client, so reusing an account would mean the second run exercises a
|
||||
/// different path from the first and neither could assert an exact vault state. This way every run
|
||||
/// starts from "no identity key, no vault".
|
||||
/// A fresh account rather than the realm's <c>alice</c>. 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.
|
||||
/// </remarks>
|
||||
internal static async Task<DevStackUser> CreateUserAsync(CancellationToken cancellationToken)
|
||||
internal async Task<DevStackUser> CreateUserAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var username = string.Create(
|
||||
CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}");
|
||||
var username = string.Create(CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}");
|
||||
|
||||
const string Password = "e2e-password";
|
||||
|
||||
using var http = new HttpClient { BaseAddress = new Uri(KeycloakBaseUrl) };
|
||||
using var http = new HttpClient { BaseAddress = KeycloakBaseUrl };
|
||||
|
||||
var token = await AdminTokenAsync(http, cancellationToken).ConfigureAwait(false);
|
||||
var token = await AdminTokenAsync(http, cancellationToken);
|
||||
|
||||
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||||
|
||||
@@ -103,15 +212,28 @@ internal static class DevStack
|
||||
},
|
||||
};
|
||||
|
||||
using var response = await http
|
||||
.PostAsJsonAsync($"/admin/realms/{Realm}/users", user, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"/admin/realms/{Realm}/users", user, cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return new DevStackUser(username, Password);
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
private static async Task<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
|
||||
{
|
||||
using var form = new FormUrlEncodedContent(
|
||||
@@ -123,37 +245,16 @@ internal static class DevStack
|
||||
["grant_type"] = "password",
|
||||
});
|
||||
|
||||
using var response = await http
|
||||
.PostAsync("/realms/master/protocol/openid-connect/token", form, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var response = await http.PostAsync(
|
||||
"/realms/master/protocol/openid-connect/token", form, cancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
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.");
|
||||
}
|
||||
|
||||
private static async Task<bool> RespondsAsync(
|
||||
HttpClient http,
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An account created for one test run.</summary>
|
||||
|
||||
Reference in New Issue
Block a user