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; } } }