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:
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user