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:
2026-07-29 12:09:15 +02:00
parent 1d262b7ccc
commit 34304b989b
9 changed files with 656 additions and 190 deletions
@@ -5,8 +5,6 @@ using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
namespace DodoSSH.SystemTests;
@@ -28,12 +26,9 @@ namespace DodoSSH.SystemTests;
/// between tests or repeating a minute of setup per assertion.
/// </para>
/// </remarks>
public sealed class M1VerticalSliceTests : IAsyncLifetime
public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStack>, IAsyncDisposable
{
private const string Passphrase = "an end to end passphrase";
private const string SshUsername = "dodo";
private const string SshPassword = "correct-horse-battery-staple";
private const int SshPort = 2222;
/// <remarks>
/// 64 MiB is the floor <c>EnrollmentLimits</c> enforces, and this suite has to respect it — the other
@@ -46,68 +41,25 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
private readonly List<string> directories = [];
private string? unavailable;
private IContainer? sshd;
/// <inheritdoc />
public async ValueTask InitializeAsync()
public ValueTask DisposeAsync()
{
unavailable = await DevStack.WhyUnavailableAsync(TestContext.Current.CancellationToken);
if (unavailable is not null)
{
// No container either. Paying half a minute of startup for a suite about to skip is how an
// opt-in suite becomes one nobody opts into.
return;
}
sshd = new ContainerBuilder("linuxserver/openssh-server:latest")
.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. Both conditions are needed, and the port check is the one that actually
// proves sshd is accepting.
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
.Build();
await sshd.StartAsync(TestContext.Current.CancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (sshd is not null)
{
await sshd.DisposeAsync();
}
foreach (var directory in directories.Where(Directory.Exists))
{
Directory.Delete(directory, recursive: true);
}
return ValueTask.CompletedTask;
}
[Fact]
public async Task TheWholeSlice()
{
if (unavailable is not null)
{
Assert.Skip(unavailable);
}
var account = await DevStack.CreateUserAsync(Token);
var account = await stack.CreateUserAsync(Token);
var browser = new ScriptedBrowser(account.Username, account.Password);
using var connection = await ServerConnection
.SignInAsync(new Uri(DevStack.ApiBaseUrl), browser, TimeProvider.System, Token);
.SignInAsync(stack.ApiBaseUrl, browser, TimeProvider.System, Token);
AssertDiscoveredFromTheServer(connection);
@@ -139,17 +91,23 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
/// The user typed one server URL. Everything about the identity provider — the authority, the client
/// id, the scopes — came back from the server, which is the whole onboarding story.
/// </remarks>
private static void AssertDiscoveredFromTheServer(ServerConnection connection)
private void AssertDiscoveredFromTheServer(ServerConnection connection)
{
connection.Configuration.Oidc.Authority.ToString()
.ShouldStartWith($"{DevStack.KeycloakBaseUrl}/realms/{DevStack.Realm}");
.ShouldStartWith(stack.Authority.ToString());
connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
// Server:PublicBaseUrl, which is what a client behind a proxy would follow. Worth asserting
// because it is configuration the server states about itself and nothing else would notice it
// being wrong.
connection.Configuration.ApiBaseUrl.ShouldBe(stack.ApiBaseUrl);
connection.Meta.SyncProtocolVersion.ShouldBe(1);
connection.Meta.CryptoSpecVersion.ShouldBe(1);
}
private static async Task EnrollAsync(
private async Task EnrollAsync(
ServerConnection connection,
ClientCacheFactory caches,
ScriptedBrowser browser)
@@ -157,11 +115,11 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
var provisioner = new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
var before = await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token);
var before = await provisioner.RefreshAsync(ServerUrl, Token);
before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
var enrolled = await provisioner.EnrollAsync(
DevStack.ApiBaseUrl, Passphrase, "e2e-laptop", "Personal", Token);
ServerUrl, Passphrase, "e2e-laptop", "Personal", Token);
enrolled.Status.ShouldBe(ProvisionStatus.Ready);
enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
@@ -211,7 +169,7 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
// Already enrolled, so this only caches what an offline unlock will need.
(await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token)).Status
(await provisioner.RefreshAsync(ServerUrl, Token)).Status
.ShouldBe(ProvisionStatus.Ready);
var desktop = await UnlockAsync(desktopCache);
@@ -253,7 +211,7 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
var factory = new SshNetConnectionFactory(knownHosts);
var request = new SshConnectionRequest(
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(SshPassword));
host.Hostname, host.Port, host.Username!, new SshPasswordCredential(DevStack.SshPassword));
try
{
@@ -280,20 +238,23 @@ public sealed class M1VerticalSliceTests : IAsyncLifetime
private static CancellationToken Token => TestContext.Current.CancellationToken;
private static HostSecret BuildHostFor(string hostname, int port) =>
/// <remarks>
/// The provisioner takes the URL as a string because it is also the cache's identity — the value an
/// offline unlock compares against to refuse a cache belonging to another server.
/// </remarks>
private string ServerUrl => stack.ApiBaseUrl.ToString();
private HostSecret BuildHost() =>
new()
{
Label = "e2e-target",
Hostname = hostname,
Port = port,
Username = SshUsername,
Hostname = stack.SshHostname,
Port = stack.SshHostPort,
Username = DevStack.SshUsername,
Notes = "created by the end-to-end slice",
Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
};
private HostSecret BuildHost() =>
BuildHostFor(sshd!.Hostname, sshd.GetMappedPublicPort(SshPort));
private async Task<ClientCacheFactory> OpenCacheAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");