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