Public Access
Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DodoSSH.SystemTests;
|
||||
|
||||
/// <summary>
|
||||
/// The development stack this suite talks to, and how to find out whether it is there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// The trade is that it cannot run unattended, so it is opt-in and says exactly what to start.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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";
|
||||
|
||||
/// <summary>
|
||||
/// Explains why the suite cannot run, or returns null when it can.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal static async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Keycloak user this test run owns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fresh account per run, rather than the realm's <c>alice</c>. 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".
|
||||
/// </remarks>
|
||||
internal static async Task<DevStackUser> 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<string> AdminTokenAsync(HttpClient http, CancellationToken cancellationToken)
|
||||
{
|
||||
using var form = new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>(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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An account created for one test run.</summary>
|
||||
internal sealed record DevStackUser(string Username, string Password);
|
||||
Reference in New Issue
Block a user