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:
@@ -0,0 +1,302 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// The DodoSSH API, running as a child process for the length of a test run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A process rather than a <c>WebApplicationFactory</c>, which is what the API's own integration suite
|
||||
/// uses. Two reasons, and both are the point of this suite. The client establishes its connection by
|
||||
/// creating an <c>HttpClient</c> 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 replaces the entry point,
|
||||
/// Kestrel and the content root — so it never proves that <c>Program.cs</c> composes, that the committed
|
||||
/// <c>appsettings.json</c> is found and layered in the documented order, or that the server answers on a
|
||||
/// socket.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It runs out of the API's own output directory, which is what makes its configuration real: the working
|
||||
/// directory becomes the content root, so the <c>appsettings.json</c> beside the assembly is the one that
|
||||
/// ships. Only the values that cannot be known before the containers start are overridden, through the
|
||||
/// <c>DODOSSH_</c> environment prefix that <c>Program.cs</c> adds last and which therefore wins.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ApiProcess : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Assembly metadata written by the <c>DodoCaptureApiPath</c> target in this csproj.</summary>
|
||||
private const string ApiPathKey = "DodoSSH.ApiAssemblyPath";
|
||||
|
||||
private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(90);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
private readonly Process process;
|
||||
private readonly StringBuilder log = new();
|
||||
|
||||
private ApiProcess(Process process, Uri baseUrl)
|
||||
{
|
||||
this.process = process;
|
||||
BaseUrl = baseUrl;
|
||||
|
||||
process.OutputDataReceived += Capture;
|
||||
process.ErrorDataReceived += Capture;
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
/// <summary>Where the API is listening.</summary>
|
||||
internal Uri BaseUrl { get; }
|
||||
|
||||
/// <summary>Everything the API has written to its console.</summary>
|
||||
/// <remarks>
|
||||
/// Kept so a failure can be explained. A child process that dies during startup is otherwise a
|
||||
/// connection refused with no cause, which is a genuinely miserable thing to debug.
|
||||
/// </remarks>
|
||||
internal string Log
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (log)
|
||||
{
|
||||
return log.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Starts the API against the given database and identity provider, and waits for readiness.</summary>
|
||||
internal static async Task<ApiProcess> StartAsync(
|
||||
string connectionString,
|
||||
Uri authority,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = new Uri(
|
||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{FreeLoopbackPort()}"));
|
||||
|
||||
var api = new ApiProcess(Launch(baseUrl, connectionString, authority), baseUrl);
|
||||
|
||||
try
|
||||
{
|
||||
await api.WaitUntilReadyAsync(cancellationToken);
|
||||
return api;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await api.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
// No graceful shutdown: Ctrl+C cannot be delivered to a specific child on Windows, and
|
||||
// the drain this would exercise belongs to the relay, which M1 does not ship.
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Exited between the check and the kill.
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
process.Dispose();
|
||||
}
|
||||
|
||||
private static Process Launch(Uri baseUrl, string connectionString, Uri authority)
|
||||
{
|
||||
var assembly = ResolveApiAssembly();
|
||||
var launcher = AppHost(assembly);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcher ?? "dotnet",
|
||||
|
||||
// The content root: WebApplication.CreateBuilder takes the working directory, and the
|
||||
// appsettings files live beside the assembly.
|
||||
WorkingDirectory = Path.GetDirectoryName(assembly)!,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
if (launcher is null)
|
||||
{
|
||||
startInfo.ArgumentList.Add("exec");
|
||||
startInfo.ArgumentList.Add(assembly);
|
||||
}
|
||||
|
||||
// Development, because the committed development configuration is precisely what this suite
|
||||
// exists to check — the realm file and appsettings.Development.json are a pair, and the first bug
|
||||
// this suite found lived in exactly that pair.
|
||||
startInfo.Environment["ASPNETCORE_ENVIRONMENT"] = "Development";
|
||||
startInfo.Environment["ASPNETCORE_URLS"] = baseUrl.ToString();
|
||||
|
||||
startInfo.Environment["DODOSSH_ConnectionStrings__Postgres"] = connectionString;
|
||||
startInfo.Environment["DODOSSH_Oidc__Authority"] = authority.ToString();
|
||||
|
||||
// Advertised to clients in the discovery document, so it has to be the port actually bound
|
||||
// rather than the 5233 a developer runs on.
|
||||
startInfo.Environment["DODOSSH_Server__PublicBaseUrl"] = baseUrl.ToString();
|
||||
startInfo.Environment["DODOSSH_Relay__WebSocketUrl"] =
|
||||
new UriBuilder(baseUrl) { Scheme = "ws", Path = "/api/v1/relay/connect" }.Uri.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
return Start(startInfo);
|
||||
}
|
||||
catch (Win32Exception) when (launcher is not null)
|
||||
{
|
||||
// The launcher is there but would not run. On Linux a checkout or artefact copy that lost the
|
||||
// execute bit is the usual cause, and the runtime can load the assembly directly regardless —
|
||||
// so this falls back rather than failing CI on a file mode.
|
||||
startInfo.FileName = "dotnet";
|
||||
startInfo.ArgumentList.Insert(0, assembly);
|
||||
startInfo.ArgumentList.Insert(0, "exec");
|
||||
|
||||
return Start(startInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private static Process Start(ProcessStartInfo startInfo) =>
|
||||
Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Could not start the API: {startInfo.FileName} produced no process.");
|
||||
|
||||
/// <summary>
|
||||
/// The native launcher beside the assembly, if the build produced one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Preferred over <c>dotnet exec</c> only because it needs nothing on PATH. The fallback is not
|
||||
/// hypothetical tidiness: a build with <c>UseAppHost=false</c> produces no launcher at all.
|
||||
/// </remarks>
|
||||
private static string? AppHost(string assembly)
|
||||
{
|
||||
var candidate = Path.ChangeExtension(assembly, OperatingSystem.IsWindows() ? ".exe" : null);
|
||||
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
private static string ResolveApiAssembly()
|
||||
{
|
||||
var path = typeof(ApiProcess).Assembly
|
||||
.GetCustomAttributes<AssemblyMetadataAttribute>()
|
||||
.FirstOrDefault(attribute => string.Equals(attribute.Key, ApiPathKey, StringComparison.Ordinal))
|
||||
?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The build did not record {ApiPathKey}. The DodoCaptureApiPath target in "
|
||||
+ "DodoSSH.SystemTests.csproj is what writes it.");
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The API was recorded at {path}, but nothing is there. Build the solution.");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Bound and released rather than left at zero, because the port has to be known before the process
|
||||
/// starts: it goes into <c>Server:PublicBaseUrl</c>, which the API reads at startup and hands to
|
||||
/// clients. The window in which another process could take it is a few milliseconds wide, and the
|
||||
/// alternative — reading the bound port back out of Kestrel's log line — cannot be fed to
|
||||
/// configuration that has already been read.
|
||||
/// </remarks>
|
||||
private static int FreeLoopbackPort()
|
||||
{
|
||||
using var probe = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
probe.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
|
||||
|
||||
return ((System.Net.IPEndPoint)probe.LocalEndPoint!).Port;
|
||||
}
|
||||
|
||||
private void Capture(object sender, DataReceivedEventArgs args)
|
||||
{
|
||||
if (args.Data is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (log)
|
||||
{
|
||||
log.AppendLine(args.Data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Readiness rather than liveness, and that distinction is load-bearing: <c>/healthz/ready</c> also
|
||||
/// asserts the database is reachable and that no migration is pending. Waiting on it means a schema the
|
||||
/// fixture failed to apply is reported here, against the API's own opinion of it, rather than as an
|
||||
/// inscrutable 500 in the middle of enrollment.
|
||||
/// </remarks>
|
||||
private async Task WaitUntilReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(ReadyTimeout);
|
||||
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
var ready = new Uri(BaseUrl, "healthz/ready");
|
||||
|
||||
try
|
||||
{
|
||||
while (!deadline.IsCancellationRequested && !process.HasExited)
|
||||
{
|
||||
if (await RespondsAsync(http, ready, deadline.Token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(PollInterval, deadline.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The deadline, not the caller. Reported below with the API's own output.
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
throw new InvalidOperationException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"The API did not become ready at {ready} within {ReadyTimeout.TotalSeconds:N0}s. "
|
||||
+ $"{(process.HasExited ? $"It exited with code {process.ExitCode}." : "It is still running.")}"
|
||||
+ $" Its output was:{Environment.NewLine}{Log}"));
|
||||
}
|
||||
|
||||
private static async Task<bool> RespondsAsync(HttpClient http, Uri url, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await http.GetAsync(url, token);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Either the per-request timeout or the deadline. The loop condition decides which.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user