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;
///
/// The DodoSSH API, running as a child process for the length of a test run.
///
///
///
/// A process rather than a WebApplicationFactory, 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 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 replaces the entry point,
/// Kestrel and the content root — so it never proves that Program.cs composes, that the committed
/// appsettings.json is found and layered in the documented order, or that the server answers on a
/// socket.
///
///
/// 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 appsettings.json beside the assembly is the one that
/// ships. Only the values that cannot be known before the containers start are overridden, through the
/// DODOSSH_ environment prefix that Program.cs adds last and which therefore wins.
///
///
internal sealed class ApiProcess : IAsyncDisposable
{
/// Assembly metadata written by the DodoCaptureApiPath target in this csproj.
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();
}
/// Where the API is listening.
internal Uri BaseUrl { get; }
/// Everything the API has written to its console.
///
/// 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.
///
internal string Log
{
get
{
lock (log)
{
return log.ToString();
}
}
}
/// Starts the API against the given database and identity provider, and waits for readiness.
internal static async Task 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;
}
}
///
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.");
///
/// The native launcher beside the assembly, if the build produced one.
///
///
/// Preferred over dotnet exec only because it needs nothing on PATH. The fallback is not
/// hypothetical tidiness: a build with UseAppHost=false produces no launcher at all.
///
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()
.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;
}
///
/// Bound and released rather than left at zero, because the port has to be known before the process
/// starts: it goes into Server:PublicBaseUrl, 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.
///
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);
}
}
///
/// Readiness rather than liveness, and that distinction is load-bearing: /healthz/ready 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.
///
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 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;
}
}
}