using System.Globalization; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes; namespace DodoSSH.SystemTests; /// /// The development stack this suite talks to, and how to find out whether it is there. /// /// /// Deliberately targets a stack the developer brought up rather than starting its own containers. That /// makes the suite test the configuration that is actually committed — the realm file, the API's /// appsettings, the migration history — instead of a parallel arrangement assembled for the test, which is /// where a divergence between "works in the suite" and "works when you run it" comes from. /// /// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start. /// /// internal static class DevStack { internal const string ApiBaseUrl = "http://localhost:5233"; internal const string KeycloakBaseUrl = "http://localhost:18080"; internal const string Realm = "dodossh"; private const string OptInVariable = "DODOSSH_E2E"; /// /// Explains why the suite cannot run, or returns null when it can. /// /// /// A message rather than a boolean, because a skipped end-to-end suite that does not say what is /// missing is a suite nobody ever runs again. /// internal static async Task WhyUnavailableAsync(CancellationToken cancellationToken) { if (Environment.GetEnvironmentVariable(OptInVariable) is not "1") { return $"Set {OptInVariable}=1 to run the end-to-end suite. It needs the development stack:" + "\n docker compose -f deploy/docker-compose.dev.yml up -d" + "\n dotnet ef database update --project src/DodoSSH.Infrastructure" + "\n dotnet run --project src/DodoSSH.Api"; } using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; if (!await RespondsAsync(http, $"{ApiBaseUrl}/healthz/ready", cancellationToken).ConfigureAwait(false)) { return $"The API is not ready at {ApiBaseUrl}. Start it with " + "`dotnet run --project src/DodoSSH.Api`, and check that migrations have been applied — " + "readiness fails while any are pending, by design."; } var discovery = $"{KeycloakBaseUrl}/realms/{Realm}/.well-known/openid-configuration"; if (!await RespondsAsync(http, discovery, cancellationToken).ConfigureAwait(false)) { return $"Keycloak is not serving the '{Realm}' realm at {KeycloakBaseUrl}. Bring it up with " + "`docker compose -f deploy/docker-compose.dev.yml up -d`."; } return null; } /// /// Creates a Keycloak user this test run owns. /// /// /// A fresh account per run, rather than the realm's alice. Enrollment happens once per account /// and cannot be undone from the client, so reusing an account would mean the second run exercises a /// different path from the first and neither could assert an exact vault state. This way every run /// starts from "no identity key, no vault". /// internal static async Task CreateUserAsync(CancellationToken cancellationToken) { var username = string.Create( CultureInfo.InvariantCulture, $"e2e-{Guid.CreateVersion7():N}"); const string Password = "e2e-password"; using var http = new HttpClient { BaseAddress = new Uri(KeycloakBaseUrl) }; var token = await AdminTokenAsync(http, cancellationToken).ConfigureAwait(false); http.DefaultRequestHeaders.Authorization = new("Bearer", token); var user = new JsonObject { ["username"] = username, ["email"] = $"{username}@example.test", ["firstName"] = "End", ["lastName"] = "ToEnd", ["enabled"] = true, ["emailVerified"] = true, ["credentials"] = new JsonArray { new JsonObject { ["type"] = "password", ["value"] = Password, ["temporary"] = false, }, }, }; using var response = await http .PostAsJsonAsync($"/admin/realms/{Realm}/users", user, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); return new DevStackUser(username, Password); } private static async Task AdminTokenAsync(HttpClient http, CancellationToken cancellationToken) { using var form = new FormUrlEncodedContent( new Dictionary(StringComparer.Ordinal) { ["client_id"] = "admin-cli", ["username"] = "admin", ["password"] = "admin", ["grant_type"] = "password", }); using var response = await http .PostAsync("/realms/master/protocol/openid-connect/token", form, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return JsonDocument.Parse(body).RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("Keycloak returned no admin access token."); } private static async Task RespondsAsync( HttpClient http, string url, CancellationToken cancellationToken) { try { using var response = await http.GetAsync(url, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch (HttpRequestException) { return false; } catch (TaskCanceledException) { return false; } } } /// An account created for one test run. internal sealed record DevStackUser(string Username, string Password);