diff --git a/DodoSSH.slnx b/DodoSSH.slnx
index dd5dde5..f65649c 100644
--- a/DodoSSH.slnx
+++ b/DodoSSH.slnx
@@ -41,6 +41,7 @@
+
diff --git a/README.md b/README.md
index ff3d022..345cfe3 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,35 @@ dotnet run --project src/DodoSSH.Api
It listens on `http://localhost:5233`, serving `/healthz/live`, `/healthz/ready` and — in
Development — `/openapi/v1.json`.
+### End-to-end verification
+
+One suite runs against a real server rather than a stub, and it is opt-in because it needs the stack:
+
+```bash
+docker compose -f deploy/docker-compose.dev.yml up -d
+```
+
+```bash
+dotnet ef database update --project src/DodoSSH.Infrastructure
+```
+
+```bash
+dotnet run --project src/DodoSSH.Api
+```
+
+```bash
+DODOSSH_E2E=1 dotnet test tests/DodoSSH.SystemTests
+```
+
+It signs in through a real Keycloak, enrolls, unlocks, creates a host, syncs it, reads it back on a second
+simulated machine, unlocks again with no network, and opens a shell on a real `sshd`. Skipped otherwise,
+with a message naming the commands above.
+
+It is worth its weight: on its first run it found a loopback redirect URI the realm registered in a form
+Keycloak rejects, and a JSON configuration gap that made the whole sync surface unreachable from the real
+client while every other test passed. Both are the same class of bug — two sides of a stub agreeing with
+each other about something the specification never said.
+
Development and testing are currently **Windows-only**. Anything known or suspected to differ on
Linux and macOS is tracked in [`docs/platform-flags.md`](docs/platform-flags.md), along with the
deployment gotchas that have already cost time once. Read it before assuming something works
@@ -110,8 +139,10 @@ off-Windows.
vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's whole
path is covered by tests against an in-memory server, so the states that matter most (the recovery code
that cannot be skipped, the unlock that needs no network) are checked rather than remembered.
- *Remaining:* the manual end-to-end run against the real API and a real Keycloak from
- `deploy/docker-compose.dev.yml`, which is what M1's definition of done actually asks for.
+ *Verified end to end:* `tests/DodoSSH.SystemTests` drives the whole slice against a real Keycloak, a
+ real API, a real PostgreSQL and a real `sshd` — sign-in, the identity-provider key binding, enrollment,
+ offline unlock, a host through the vault to a second machine, and an interactive shell. See
+ [End-to-end verification](#end-to-end-verification).
Known gaps in the client, stated rather than implied by the interface: credentials are not a synced
entity type yet, so a connection still asks for a password; known host keys live in memory for one
diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml
index 5df19e6..d799eed 100644
--- a/deploy/docker-compose.dev.yml
+++ b/deploy/docker-compose.dev.yml
@@ -38,8 +38,15 @@ services:
keycloak:
image: quay.io/keycloak/keycloak:26.4
container_name: dodossh-dev-keycloak
- # start-dev, never in production: it disables HTTPS enforcement and uses an in-memory
- # database. The realm is imported on every start so this stays disposable.
+ # start-dev, never in production: it disables HTTPS enforcement and keeps its state in a
+ # throwaway H2 database inside the container.
+ #
+ # --import-realm skips a realm that already exists, so editing realm-dodossh.json and running
+ # `restart` does nothing at all — the edit appears to have no effect and the old configuration
+ # keeps being served. To pick up a change, recreate the container so its H2 state goes with it:
+ #
+ # docker compose -f deploy/docker-compose.dev.yml rm -sf keycloak
+ # docker compose -f deploy/docker-compose.dev.yml up -d keycloak
command: ["start-dev", "--import-realm"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
diff --git a/deploy/keycloak/realm-dodossh.json b/deploy/keycloak/realm-dodossh.json
index bed404f..7626467 100644
--- a/deploy/keycloak/realm-dodossh.json
+++ b/deploy/keycloak/realm-dodossh.json
@@ -23,11 +23,10 @@
"implicitFlowEnabled": false,
"attributes": {
"pkce.code.challenge.method": "S256",
- "post.logout.redirect.uris": "http://127.0.0.1:*/*"
+ "post.logout.redirect.uris": "http://127.0.0.1/*"
},
"redirectUris": [
- "http://127.0.0.1:*/callback",
- "http://localhost:*/callback"
+ "http://127.0.0.1/callback"
],
"webOrigins": [],
"protocolMappers": [
diff --git a/docs/platform-flags.md b/docs/platform-flags.md
index 1565ef9..893b6d9 100644
--- a/docs/platform-flags.md
+++ b/docs/platform-flags.md
@@ -103,6 +103,40 @@ minimal desktop or inside a Flatpak sandbox — where the portal is the correct
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
so a platform-specific opener can be substituted without touching the flow.
+## Identity provider
+
+**A loopback redirect URI must be registered without a port, not with a wildcard port.** Keycloak — and
+providers implementing RFC 8252 §7.3 generally — ignores the port when the registered redirect URI's host
+is a loopback literal, which is what lets a native client bind an ephemeral port. Registering
+`http://127.0.0.1:*/callback` looks more explicit and is *broken*: the `*` is parsed as a literal port and
+every real authorization request comes back `400 Invalid parameter: redirect_uri`. Keycloak's wildcard
+support is trailing-only, so a `*` in the middle of a URI never means what it looks like.
+
+Register `http://127.0.0.1/callback`. Keep the path — it is the part that stops another process on the
+machine having an authorization code delivered to a different endpoint. `Oidc:LoopbackRedirectPattern`,
+which the server advertises through `/.well-known/dodossh-configuration`, says the same thing so an
+operator configuring a different provider copies something that works.
+
+Found by running the sign-in against a real Keycloak; every test until then used a stub that accepted
+whatever it was given.
+
+**Keycloak marks its session cookies `Secure` even over plain HTTP**, because `SameSite=None` is only
+legal alongside `Secure`. A spec-conformant HTTP client therefore refuses to store them from an `http://`
+origin — .NET's `CookieContainer` drops every one silently — and the login form POST then comes back
+`400` with no explanation at all. Browsers complete the flow because they treat loopback as a trustworthy
+origin and make the exception.
+
+This does not affect the product: the client uses the system browser, which makes that exception. It does
+affect any non-browser automation against a development Keycloak, which has to carry the cookies by hand
+(see `ScriptedBrowser`) or be given HTTPS. Two hours of "the credentials must be wrong".
+
+**`--import-realm` skips a realm that already exists.** Editing `deploy/keycloak/realm-dodossh.json` and
+running `docker compose restart keycloak` therefore changes nothing, and the stale configuration keeps
+being served — which reads exactly like the edit being wrong. `start-dev` keeps its state in an H2
+database inside the container, so the realm has to be recreated along with it:
+`docker compose rm -sf keycloak && docker compose up -d keycloak`. Cost an otherwise inexplicable
+debugging detour.
+
## Local cache
**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it:
diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs
index 4466112..fed8d34 100644
--- a/src/DodoSSH.Api/Program.cs
+++ b/src/DodoSSH.Api/Program.cs
@@ -11,6 +11,7 @@ var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddKeyPerFile("/run/secrets", optional: true);
builder.Configuration.AddEnvironmentVariables(prefix: "DODOSSH_");
+builder.Services.AddDodoJson();
builder.Services.AddDodoOptions();
builder.Services.AddDodoPersistence(builder.Configuration);
builder.Services.AddDodoAuthentication();
diff --git a/src/DodoSSH.Api/Setup/DodoOptions.cs b/src/DodoSSH.Api/Setup/DodoOptions.cs
index ce79c20..97edac1 100644
--- a/src/DodoSSH.Api/Setup/DodoOptions.cs
+++ b/src/DodoSSH.Api/Setup/DodoOptions.cs
@@ -34,7 +34,15 @@ public sealed class OidcOptions
/// an ephemeral port with the system browser, never a custom scheme and never an embedded
/// browser, so the user can see the real address bar.
///
- public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1:*/callback";
+ ///
+ /// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and
+ /// providers implement that by ignoring the port when the host is a loopback literal — Keycloak
+ /// included. Writing http://127.0.0.1:*/callback looks more explicit and is worse: Keycloak
+ /// parses the * as a literal port and rejects every real redirect with "Invalid parameter:
+ /// redirect_uri". Pinning the path is the part that matters, since it stops another local process
+ /// having a code delivered somewhere else.
+ ///
+ public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";
/// Whether HTTPS metadata is required. Only ever false for local development.
public bool RequireHttpsMetadata { get; set; } = true;
diff --git a/src/DodoSSH.Api/Setup/Json.cs b/src/DodoSSH.Api/Setup/Json.cs
new file mode 100644
index 0000000..6c0fc19
--- /dev/null
+++ b/src/DodoSSH.Api/Setup/Json.cs
@@ -0,0 +1,28 @@
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Api.Setup;
+
+/// Brings the host's JSON handling into line with the shared contract.
+internal static class Json
+{
+ ///
+ /// Applies 's settings to the minimal-API serialiser.
+ ///
+ ///
+ ///
+ /// Not optional, and not a performance tweak. Without it the framework's web defaults apply: camelCase
+ /// property names — which happen to match — but enums as numbers, and no rejection of unmapped
+ /// members. Every request DTO carrying an enum then fails to bind against a client that writes the
+ /// specified string form, which is the entire sync surface. The failure is a 400 naming only the
+ /// parameter, and it is invisible to any test that posts with its own default options rather than the
+ /// contract's.
+ ///
+ ///
+ /// Found by running the real client against the real server for the first time. Every test until then
+ /// serialised its requests with PostAsJsonAsync's defaults, so both sides agreed on integers and
+ /// nothing disagreed with anything.
+ ///
+ ///
+ internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
+ services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions));
+}
diff --git a/src/DodoSSH.Api/appsettings.json b/src/DodoSSH.Api/appsettings.json
index a40458d..9dbf573 100644
--- a/src/DodoSSH.Api/appsettings.json
+++ b/src/DodoSSH.Api/appsettings.json
@@ -13,7 +13,7 @@
"Oidc": {
"Audience": "dodossh-api",
"ClientId": "dodossh-desktop",
- "LoopbackRedirectPattern": "http://127.0.0.1:*/callback",
+ "LoopbackRedirectPattern": "http://127.0.0.1/callback",
"RequireHttpsMetadata": true,
"AllowEmailLinking": false
},
diff --git a/src/DodoSSH.Contracts/DodoSshJsonContext.cs b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
index d7fdd18..8b6d092 100644
--- a/src/DodoSSH.Contracts/DodoSshJsonContext.cs
+++ b/src/DodoSSH.Contracts/DodoSshJsonContext.cs
@@ -72,6 +72,55 @@ public sealed partial class DodoSshJsonContext : JsonSerializerContext
///
public static JsonSerializerOptions StrictRequestOptions => LazyStrictRequestOptions.Value;
+ ///
+ /// Applies these settings to options this process does not own.
+ ///
+ ///
+ ///
+ /// ASP.NET Core exposes its JSON options as a get-only already
+ /// constructed from , so a host cannot simply hand it
+ /// — it has to be mutated in place. That copying lives here, next
+ /// to the settings it has to mirror, so adding a setting above is one edit rather than two in different
+ /// projects with nothing connecting them.
+ ///
+ ///
+ /// Why this matters more than it looks. Without it the framework defaults apply, and those
+ /// serialise an enum as a number. Every request DTO carrying one — which is the whole sync surface —
+ /// then fails to bind against a client that writes the specified string form, with a 400 that names
+ /// only the parameter. Nothing catches it if the tests post with their own default options, because
+ /// both sides then agree on a form the specification never described.
+ ///
+ ///
+ /// Options to bring into line. Must not already be read-only.
+ public static void ApplyTo(JsonSerializerOptions target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+
+ var source = StrictRequestOptions;
+
+ target.PropertyNamingPolicy = source.PropertyNamingPolicy;
+ target.PropertyNameCaseInsensitive = source.PropertyNameCaseInsensitive;
+ target.NumberHandling = source.NumberHandling;
+ target.DefaultIgnoreCondition = source.DefaultIgnoreCondition;
+ target.UnmappedMemberHandling = source.UnmappedMemberHandling;
+ target.DictionaryKeyPolicy = source.DictionaryKeyPolicy;
+
+ foreach (var converter in source.Converters)
+ {
+ target.Converters.Add(converter);
+ }
+
+ // Inserted at the front rather than assigned, so contract types use the source-generated metadata
+ // while any resolver the caller already installed keeps handling everything else — ProblemDetails
+ // among them, which a host must still be able to write.
+ //
+ // Options carrying no resolver at all end up with a chain containing only this context, and
+ // anything outside the contract then throws NotSupportedException. That is deliberate: adding a
+ // reflection fallback here would quietly cost trimmability, which is half the reason this context
+ // is source-generated. ASP.NET Core's own JSON options already have one.
+ target.TypeInfoResolverChain.Insert(0, Default);
+ }
+
///
/// Lazy, not a static initialiser. The generated Default property is a static of this
/// same class, so reading it from this type's initialiser is a cycle: the accessor runs
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index 989c765..42b87e5 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -523,6 +523,7 @@ static DodoSSH.Contracts.DirectoryEntry.operator !=(DodoSSH.Contracts.DirectoryE
static DodoSSH.Contracts.DirectoryEntry.operator ==(DodoSSH.Contracts.DirectoryEntry? left, DodoSSH.Contracts.DirectoryEntry? right) -> bool
static DodoSSH.Contracts.DodoSshConfiguration.operator !=(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool
static DodoSSH.Contracts.DodoSshConfiguration.operator ==(DodoSSH.Contracts.DodoSshConfiguration? left, DodoSSH.Contracts.DodoSshConfiguration? right) -> bool
+static DodoSSH.Contracts.DodoSshJsonContext.ApplyTo(System.Text.Json.JsonSerializerOptions! target) -> void
static DodoSSH.Contracts.DodoSshJsonContext.ResponseOptions.get -> System.Text.Json.JsonSerializerOptions!
static DodoSSH.Contracts.DodoSshJsonContext.StrictRequestOptions.get -> System.Text.Json.JsonSerializerOptions!
static DodoSSH.Contracts.EncryptedPayload.operator !=(DodoSSH.Contracts.EncryptedPayload? left, DodoSSH.Contracts.EncryptedPayload? right) -> bool
diff --git a/tests/DodoSSH.Api.Tests/ContractJson.cs b/tests/DodoSSH.Api.Tests/ContractJson.cs
new file mode 100644
index 0000000..106fdf1
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/ContractJson.cs
@@ -0,0 +1,57 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using DodoSSH.Contracts;
+
+namespace DodoSSH.Api.Tests;
+
+///
+/// Sends and reads request bodies the way a real client does.
+///
+///
+///
+/// Not a convenience. PostAsJsonAsync's default options write an enum as a number, and the shared
+/// contract writes it as a string. Tests that used the defaults therefore agreed with a server that had
+/// also been left on the defaults, and the pair of them agreed on a wire form the specification never
+/// described — so the entire sync surface was unreachable from the real client and every test passed.
+///
+///
+/// Everything here goes through for that reason. If the server's JSON
+/// configuration regresses, these tests are the ones that must fail.
+///
+///
+internal static class ContractJson
+{
+ private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions;
+
+ internal static Task PostContractAsync(
+ this HttpClient client,
+ string url,
+ T value)
+ {
+ ArgumentNullException.ThrowIfNull(client);
+
+ return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
+ }
+
+ internal static Task ReadContractAsync(this HttpContent content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ return content.ReadFromJsonAsync(Options, TestContext.Current.CancellationToken);
+ }
+
+ /// Reads an RFC 9457 problem body.
+ ///
+ /// Deliberately not through the contract options. Problem details are written by the framework
+ /// and are not part of 's source-generated set, so resolving them
+ /// against it fails outright — a source-generated context does not fall back to reflection. Reading
+ /// them with the ambient web options is correct rather than a shortcut: the shape is the RFC's, not
+ /// ours, and only the code extension belongs to us.
+ ///
+ internal static Task ReadProblemAsync(this HttpContent content)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+
+ return content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ }
+}
diff --git a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
index 6c92636..3d6c5e4 100644
--- a/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/IdentityEndpointTests.cs
@@ -277,7 +277,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
await EnrollAsync(client, enrollment.Build());
- var push = await client.PostAsJsonAsync(
+ var push = await client.PostContractAsync(
$"/api/v1/vaults/{enrollment.VaultId}/sync/push",
new SyncPushRequest(
[
@@ -293,7 +293,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
push.EnsureSuccessStatusCode();
- var body = await push.Content.ReadFromJsonAsync();
+ var body = await push.Content.ReadContractAsync();
body.ShouldNotBeNull();
body.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
}
@@ -360,7 +360,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
try
{
var responses = await Task.WhenAll(enrollments.Select(e =>
- e.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, e.Build())));
+ e.CreateClient(fixture).PostContractAsync(EnrollUrl, e.Build())));
foreach (var response in responses)
{
@@ -418,14 +418,14 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
var responses = await Task.WhenAll(
Enumerable.Range(0, 4).Select(_ =>
- enrollment.CreateClient(fixture).PostAsJsonAsync(EnrollUrl, request)));
+ enrollment.CreateClient(fixture).PostContractAsync(EnrollUrl, request)));
var bodies = new List(responses.Length);
foreach (var response in responses)
{
response.EnsureSuccessStatusCode();
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body.ShouldNotBeNull();
bodies.Add(body);
}
@@ -457,7 +457,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var second = new TestEnrollment(fixture.IdentityProvider, first.Subject);
- var response = await client.PostAsJsonAsync(EnrollUrl, second.Build());
+ var response = await client.PostContractAsync(EnrollUrl, second.Build());
await ShouldBeProblemAsync(response, HttpStatusCode.Conflict, ProblemCodes.AlreadyEnrolled);
}
@@ -469,7 +469,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
var client = enrollment.CreateClient(fixture);
await EnrollAsync(client, enrollment.Build());
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
EnrollUrl,
enrollment.Build(personalVault: enrollment.DefaultVault(vaultId: Guid.CreateVersion7())));
@@ -484,7 +484,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var second = NewEnrollment();
- var response = await second.CreateClient(fixture).PostAsJsonAsync(
+ var response = await second.CreateClient(fixture).PostContractAsync(
EnrollUrl,
second.Build(personalVault: second.DefaultVault(vaultId: owner.VaultId)));
@@ -504,7 +504,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
var other = enrollment.Statement with { DeviceName = "some-other-device" };
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(idToken: enrollment.MintIdToken(other)));
@@ -516,7 +516,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
idToken: enrollment.MintIdToken(enrollment.Statement, subject: NewSubject())));
@@ -531,7 +531,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
// different kind of assertion entirely, and must not be interchangeable with one.
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
idToken: enrollment.MintIdToken(
@@ -551,7 +551,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
enrollment.Subject,
KeyStatementCodec.ComputeNonce(Fields(enrollment.Statement)));
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(idToken: foreign));
@@ -563,7 +563,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
idToken: enrollment.MintIdToken(
@@ -578,7 +578,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
idToken: enrollment.MintIdToken(enrollment.Statement, issuer: "https://evil.example")));
@@ -591,7 +591,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
@@ -607,7 +607,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
var client = enrollment.CreateClient(fixture);
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
EnrollUrl,
enrollment.Build(idToken: enrollment.MintIdToken(enrollment.Statement, omitNonce: true)));
@@ -633,7 +633,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
using var other = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(statementSignature: other.Sign(enrollment.Statement)));
@@ -651,7 +651,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
var impersonating = enrollment.Statement with { Subject = NewSubject() };
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(statement: impersonating));
@@ -676,7 +676,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
kdfParameters: new KdfParameters("argon2id", new byte[16], memoryKibibytes, passes, 1)));
@@ -692,7 +692,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
kdfParameters: new KdfParameters("argon2id", new byte[16], 256 * 1024, 4, 4)));
@@ -712,7 +712,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
// The signature and token are supplied ready-made: a malformed statement cannot be
// canonically encoded at all, so neither can be derived from it. Shape validation runs
// before any cryptography, so the server never gets that far either.
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(
statement: truncated,
@@ -736,7 +736,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
EncryptionPublicKey = enrollment.Statement.SigningPublicKey,
};
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(statement: reused));
@@ -752,7 +752,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
using var enrollment = NewEnrollment();
var later = enrollment.Statement with { KeyGeneration = 2 };
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build(statement: later));
@@ -769,7 +769,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
// device that can never unlock anything and that the server could never fix.
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build() with { DeviceWrappedPrivateKey = null });
@@ -784,7 +784,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build() with { RecoveryKdfParameters = null });
@@ -819,7 +819,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await enrollment.CreateClient(fixture).PostAsJsonAsync(
+ var response = await enrollment.CreateClient(fixture).PostContractAsync(
EnrollUrl,
enrollment.Build() with { KdfParameters = null! });
@@ -834,7 +834,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
using var enrollment = NewEnrollment();
- var response = await fixture.CreateClient().PostAsJsonAsync(EnrollUrl, enrollment.Build());
+ var response = await fixture.CreateClient().PostContractAsync(EnrollUrl, enrollment.Build());
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
}
@@ -854,7 +854,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
var response = await client.GetAsync(new Uri(MeUrl, UriKind.Relative));
response.EnsureSuccessStatusCode();
- var me = await response.Content.ReadFromJsonAsync();
+ var me = await response.Content.ReadContractAsync();
me.ShouldNotBeNull();
return me;
}
@@ -863,10 +863,10 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
HttpClient client,
EnrollmentRequest request)
{
- var response = await client.PostAsJsonAsync(EnrollUrl, request);
+ var response = await client.PostContractAsync(EnrollUrl, request);
response.EnsureSuccessStatusCode();
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body.ShouldNotBeNull();
return body;
}
@@ -884,7 +884,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
{
response.StatusCode.ShouldBe(expectedStatus);
- var problem = await response.Content.ReadFromJsonAsync();
+ var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(expectedCode);
}
diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
index 1a9eced..f0beec1 100644
--- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs
@@ -28,7 +28,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
{
var client = fixture.CreateClient();
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -40,7 +40,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
{
var client = fixture.CreateClient();
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PushUrl(Guid.CreateVersion7()),
new SyncPushRequest([]));
@@ -54,7 +54,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientWithToken(
fixture.IdentityProvider.MintTokenWithForeignKey(NewSubject()));
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -67,7 +67,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientWithToken(
fixture.IdentityProvider.MintToken(NewSubject(), audience: "some-other-api"));
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -80,7 +80,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientWithToken(
fixture.IdentityProvider.MintToken(NewSubject(), issuer: "https://evil.example"));
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -94,7 +94,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
NewSubject(),
expires: TimeProvider.System.GetUtcNow().UtcDateTime.AddMinutes(-10)));
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -111,13 +111,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
// ciphertext it has no key for.
var client = fixture.CreateClientFor(NewSubject());
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
- var problem = await response.Content.ReadFromJsonAsync();
+ var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
}
@@ -128,7 +128,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (_, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(NewSubject());
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
+ var response = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
@@ -147,7 +147,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (_, vaultId) = await SeedUserWithVaultAsync();
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
- var response = await intruder.PostAsJsonAsync(
+ var response = await intruder.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null));
@@ -160,7 +160,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (_, vaultId) = await SeedUserWithVaultAsync();
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
- var response = await intruder.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
+ var response = await intruder.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
@@ -173,7 +173,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var intruder = fixture.CreateClientFor(await SeedEnrolledUserAsync());
var batch = NewCreateBatch();
- await intruder.PostAsJsonAsync(PushUrl(vaultId), batch);
+ await intruder.PostContractAsync(PushUrl(vaultId), batch);
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService();
@@ -187,7 +187,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
{
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(Guid.CreateVersion7()),
new SyncPullRequest(null, null, null));
@@ -201,7 +201,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var vaultId = await SeedTeamVaultAsync();
var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null));
@@ -217,21 +217,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientFor(subject);
var batch = NewCreateBatch();
- var push = await client.PostAsJsonAsync(PushUrl(vaultId), batch);
+ var push = await client.PostContractAsync(PushUrl(vaultId), batch);
push.EnsureSuccessStatusCode();
- var pushed = await push.Content.ReadFromJsonAsync();
+ var pushed = await push.Content.ReadContractAsync();
pushed.ShouldNotBeNull();
pushed.Results.Count.ShouldBe(1);
pushed.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
pushed.Results[0].Version.ShouldBe(1);
- var pull = await client.PostAsJsonAsync(
+ var pull = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null));
pull.EnsureSuccessStatusCode();
- var pulled = await pull.Content.ReadFromJsonAsync();
+ var pulled = await pull.Content.ReadContractAsync();
pulled.ShouldNotBeNull();
pulled.Changes.Count.ShouldBe(1);
@@ -248,30 +248,30 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
+ await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
- var first = await (await client.PostAsJsonAsync(
+ var first = await (await client.PostContractAsync(
PullUrl(vaultId),
- new SyncPullRequest(null, null, null))).Content.ReadFromJsonAsync();
+ new SyncPullRequest(null, null, null))).Content.ReadContractAsync();
first.ShouldNotBeNull();
// Nothing new since that cursor.
- var empty = await (await client.PostAsJsonAsync(
+ var empty = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(first.NextCursor, null, null)))
- .Content.ReadFromJsonAsync();
+ .Content.ReadContractAsync();
empty.ShouldNotBeNull();
empty.Changes.ShouldBeEmpty();
// The cursor must not have rewound, or the next poll would replay history.
empty.NextCursor.ShouldBe(first.NextCursor);
- await client.PostAsJsonAsync(PushUrl(vaultId), NewCreateBatch());
+ await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
- var second = await (await client.PostAsJsonAsync(
+ var second = await (await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(first.NextCursor, null, null)))
- .Content.ReadFromJsonAsync();
+ .Content.ReadContractAsync();
second.ShouldNotBeNull();
second.Changes.Count.ShouldBe(1);
}
@@ -283,13 +283,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (_, otherVault) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var pull = await client.PostAsJsonAsync(
+ var pull = await client.PostContractAsync(
PullUrl(firstVault),
new SyncPullRequest(null, null, null));
- var cursor = (await pull.Content.ReadFromJsonAsync())!.NextCursor;
+ var cursor = (await pull.Content.ReadContractAsync())!.NextCursor;
// Correctly signed, but issued for a different vault.
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(otherVault),
new SyncPullRequest(cursor, null, null));
@@ -303,13 +303,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var response = await client.PostAsJsonAsync(
+ var response = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest("bm90LWEtcmVhbC1jdXJzb3I", null, null));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
- var problem = await response.Content.ReadFromJsonAsync();
+ var problem = await response.Content.ReadProblemAsync();
problem.ShouldNotBeNull();
problem.Code.ShouldBe(ProblemCodes.InvalidCursor);
}
@@ -324,23 +324,23 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
- await client.PostAsJsonAsync(PushUrl(vaultId), create);
+ await client.PostContractAsync(PushUrl(vaultId), create);
// Update to version 2.
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]),
]));
// A second client still believes it is on version 1.
- var stale = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ var stale = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]),
]));
stale.EnsureSuccessStatusCode();
- var body = await stale.Content.ReadFromJsonAsync();
+ var body = await stale.Content.ReadContractAsync();
body.ShouldNotBeNull();
body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);
body.Results[0].Version.ShouldBe(2);
@@ -358,13 +358,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
- await client.PostAsJsonAsync(PushUrl(vaultId), create);
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), create);
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [9, 9, 9]),
]));
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [7, 7, 7]),
]));
@@ -386,14 +386,14 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var batch = NewCreateBatch();
- var first = await client.PostAsJsonAsync(PushUrl(vaultId), batch);
+ var first = await client.PostContractAsync(PushUrl(vaultId), batch);
first.EnsureSuccessStatusCode();
// Exactly the same batch again, as a retry after a timeout would be.
- var replay = await client.PostAsJsonAsync(PushUrl(vaultId), batch);
+ var replay = await client.PostContractAsync(PushUrl(vaultId), batch);
replay.EnsureSuccessStatusCode();
- var body = await replay.Content.ReadFromJsonAsync();
+ var body = await replay.Content.ReadContractAsync();
body.ShouldNotBeNull();
body.Results[0].Status.ShouldBe(SyncOperationStatus.Duplicate);
@@ -414,7 +414,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientFor(subject);
var existing = NewCreateBatch();
- await client.PostAsJsonAsync(PushUrl(vaultId), existing);
+ await client.PostContractAsync(PushUrl(vaultId), existing);
var goodId = Guid.CreateVersion7();
var mixed = new SyncPushRequest(
@@ -423,12 +423,12 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
NewOperation(goodId, expectedVersion: null, envelope: [2, 2]),
]);
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), mixed);
+ var response = await client.PostContractAsync(PushUrl(vaultId), mixed);
// 200 despite a failed operation: per-operation status carries the detail.
response.StatusCode.ShouldBe(HttpStatusCode.OK);
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body.ShouldNotBeNull();
body.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);
body.Results[1].Status.ShouldBe(SyncOperationStatus.Applied);
@@ -447,7 +447,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -461,7 +461,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
response.EnsureSuccessStatusCode();
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
}
@@ -471,7 +471,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -485,7 +485,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
response.EnsureSuccessStatusCode();
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
}
@@ -497,7 +497,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var client = fixture.CreateClientFor(subject);
var entityId = Guid.CreateVersion7();
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -509,7 +509,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
new SyncPlaintextFields(RelayEnabled: true, Hostname: "bastion.internal", Port: 22)),
]));
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -539,9 +539,9 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
- await client.PostAsJsonAsync(PushUrl(vaultId), create);
+ await client.PostContractAsync(PushUrl(vaultId), create);
- await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -553,11 +553,11 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
PlaintextFields: null),
]));
- var pull = await client.PostAsJsonAsync(
+ var pull = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, null));
- var body = await pull.Content.ReadFromJsonAsync();
+ var body = await pull.Content.ReadContractAsync();
body.ShouldNotBeNull();
var tombstone = body.Changes.Last(c => c.EntityId == entityId);
@@ -574,7 +574,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest([]));
+ var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([]));
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
@@ -587,7 +587,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
- var response = await client.PostAsJsonAsync(PushUrl(vaultId), new SyncPushRequest(
+ var response = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(),
@@ -601,7 +601,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
response.EnsureSuccessStatusCode();
- var body = await response.Content.ReadFromJsonAsync();
+ var body = await response.Content.ReadContractAsync();
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
}
diff --git a/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs b/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs
new file mode 100644
index 0000000..ccd0e00
--- /dev/null
+++ b/tests/DodoSSH.Contracts.Tests/ApplyToTests.cs
@@ -0,0 +1,146 @@
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+namespace DodoSSH.Contracts.Tests;
+
+///
+/// Applying the contract's settings to options this process does not own.
+///
+///
+/// The host cannot be handed StrictRequestOptions — ASP.NET Core exposes its serialiser options as
+/// a get-only instance already built from the web defaults — so they have to be copied in. Copying is
+/// exactly the hazard the context's own documentation warns about, which is why every setting that
+/// distinguishes the contract from those defaults is asserted here rather than assumed.
+///
+/// The consequence of getting this wrong is not subtle: the web defaults write an enum as a number, and
+/// every request DTO carrying one then fails to bind against a real client.
+///
+///
+public sealed class ApplyToTests
+{
+ [Fact]
+ public void AppliedOptions_WriteEnumsAsStrings()
+ {
+ // The setting that actually broke. A number here means the sync surface is unreachable.
+ var change = new SyncChange(
+ SyncEntityType.Host,
+ Guid.CreateVersion7(),
+ SyncOperation.Upsert,
+ Version: 1,
+ ChangeSequence: 1,
+ Payload: null,
+ PlaintextFields: null,
+ UpdatedAt: DateTimeOffset.UnixEpoch);
+
+ var json = JsonSerializer.Serialize(change, Applied());
+
+ json.ShouldContain("\"Host\"");
+ json.ShouldContain("\"Upsert\"");
+ json.ShouldNotContain("\"entityType\":1");
+ }
+
+ [Fact]
+ public void AppliedOptions_ReadEnumsAsStrings()
+ {
+ var request = new SyncPullRequest("cursor", 100, [SyncEntityType.Host, SyncEntityType.Credential]);
+
+ var options = Applied();
+
+ var restored = JsonSerializer.Deserialize(
+ JsonSerializer.Serialize(request, options), options);
+
+ restored.ShouldNotBeNull();
+ restored.EntityTypes.ShouldBe(request.EntityTypes);
+ }
+
+ [Fact]
+ public void AppliedOptions_RejectAnUnmappedMember()
+ {
+ // The documented reason StrictRequestOptions exists: a renamed or misspelled property surfaces as
+ // a 400 rather than as a silently missing value that later looks like data loss.
+ const string Body = """{"cursor":"c","limit":10,"entityTypes":null,"typo":true}""";
+
+ Should.Throw(() => JsonSerializer.Deserialize(Body, Applied()));
+ }
+
+ [Fact]
+ public void AppliedOptions_RejectANumberInAString()
+ {
+ // JsonSerializerDefaults.Web replaces Strict with AllowReadingFromString, and two implementations
+ // that disagree about whether "1" is a number disagree silently.
+ const string Body = """
+ {"envelope":"AQID","wrappedDataKey":"BAU=",
+ "dataKeyId":"0192f0c8-0000-7000-8000-000000000000",
+ "keyGeneration":"1","aadVersion":1}
+ """;
+
+ Should.Throw(() => JsonSerializer.Deserialize(Body, Applied()));
+ }
+
+ [Fact]
+ public void AppliedOptions_UseCamelCase()
+ {
+ // Compared exactly. Shouldly's ShouldNotContain is case-insensitive, so asserting the absence of
+ // "Cursor" would pass on "cursor" and prove nothing.
+ JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), Applied())
+ .ShouldBe("""{"cursor":"c","limit":1}""");
+ }
+
+ [Fact]
+ public void AppliedOptions_MatchTheContractsOwnSettings()
+ {
+ // A blanket comparison, so a setting added to the source-generation attributes and forgotten in
+ // ApplyTo fails here rather than at a wire boundary.
+ var applied = Applied();
+ var contract = DodoSshJsonContext.StrictRequestOptions;
+
+ applied.PropertyNamingPolicy.ShouldBe(contract.PropertyNamingPolicy);
+ applied.NumberHandling.ShouldBe(contract.NumberHandling);
+ applied.DefaultIgnoreCondition.ShouldBe(contract.DefaultIgnoreCondition);
+ applied.UnmappedMemberHandling.ShouldBe(contract.UnmappedMemberHandling);
+ applied.PropertyNameCaseInsensitive.ShouldBe(contract.PropertyNameCaseInsensitive);
+ applied.DictionaryKeyPolicy.ShouldBe(contract.DictionaryKeyPolicy);
+ }
+
+ [Fact]
+ public void AppliedOptions_LeaveTheCallersOwnResolverInPlace()
+ {
+ // The guarantee a host depends on. The context goes in front so contract types use the generated
+ // metadata, and whatever the host already installed keeps handling everything else —
+ // ProblemDetails among them. Replacing the chain would break every framework type a server writes.
+ JsonSerializer.Serialize(new Unrelated("value"), Applied()).ShouldBe("""{"name":"value"}""");
+ }
+
+ [Fact]
+ public void WithoutAFallbackResolver_TypesOutsideTheContractAreRefused()
+ {
+ // Stated rather than assumed, because it is the trade being made: a reflection fallback inside
+ // ApplyTo would cost trimmability, which is half the reason this context is source-generated.
+ // Options carrying no resolver are the caller's problem, loudly rather than silently.
+ var bare = new JsonSerializerOptions(JsonSerializerDefaults.Web);
+ DodoSshJsonContext.ApplyTo(bare);
+
+ Should.Throw(() => JsonSerializer.Serialize(new Unrelated("v"), bare));
+
+ // Contract types still work, so the failure is narrow rather than total.
+ JsonSerializer.Serialize(new SyncPullRequest("c", 1, null), bare).ShouldNotBeNullOrEmpty();
+ }
+
+ ///
+ /// Mirrors ASP.NET Core's own options: web defaults with a reflection resolver already
+ /// installed, which is the state ConfigureHttpJsonOptions hands over.
+ ///
+ private static JsonSerializerOptions Applied()
+ {
+ var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
+ {
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ };
+
+ DodoSshJsonContext.ApplyTo(options);
+
+ return options;
+ }
+
+ private sealed record Unrelated(string Name);
+}
diff --git a/tests/DodoSSH.SystemTests/DevStack.cs b/tests/DodoSSH.SystemTests/DevStack.cs
new file mode 100644
index 0000000..6ff3f0a
--- /dev/null
+++ b/tests/DodoSSH.SystemTests/DevStack.cs
@@ -0,0 +1,160 @@
+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);
diff --git a/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj b/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj
new file mode 100644
index 0000000..fb4df43
--- /dev/null
+++ b/tests/DodoSSH.SystemTests/DodoSSH.SystemTests.csproj
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+ --ignore-exit-code 8
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
new file mode 100644
index 0000000..a28a31f
--- /dev/null
+++ b/tests/DodoSSH.SystemTests/M1VerticalSliceTests.cs
@@ -0,0 +1,371 @@
+using System.Text;
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+
+namespace DodoSSH.SystemTests;
+
+///
+/// M1's definition of done: sign in, enroll, unlock, create a host, sync, read it on a second machine,
+/// and open a shell on it.
+///
+///
+///
+/// Nothing is stubbed. A real Keycloak issues the tokens and signs the key binding, a real API stores the
+/// ciphertext in a real PostgreSQL, real DSH1 crypto seals and opens it, and a real sshd answers at
+/// the end. Every other suite substitutes at least one of those, and each substitution is a place where a
+/// misreading of the protocol can be consistent on both sides and still wrong in production — which is
+/// exactly what this found the first time it ran.
+///
+///
+/// One test rather than several, because the steps are not independent: you cannot unlock without having
+/// enrolled, and enrollment happens once per account. Splitting them would mean sharing mutable state
+/// between tests or repeating a minute of setup per assertion.
+///
+///
+public sealed class M1VerticalSliceTests : IAsyncLifetime
+{
+ private const string Passphrase = "an end to end passphrase";
+ private const string SshUsername = "dodo";
+ private const string SshPassword = "correct-horse-battery-staple";
+ private const int SshPort = 2222;
+
+ ///
+ /// 64 MiB is the floor EnrollmentLimits enforces, and this suite has to respect it — the other
+ /// client suites use 8 MiB because their in-memory servers have no policy, and a real one rejects that
+ /// outright. Worth knowing rather than discovering: the reduction those suites take for speed is only
+ /// available because nothing is checking, and the difference is a 400 rather than a slow test.
+ ///
+ private static readonly Argon2Profile ServerFloorProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 64 * 1024, passes: 3, parallelism: 1);
+
+ private readonly List directories = [];
+
+ private string? unavailable;
+ private IContainer? sshd;
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ unavailable = await DevStack.WhyUnavailableAsync(TestContext.Current.CancellationToken);
+
+ if (unavailable is not null)
+ {
+ // No container either. Paying half a minute of startup for a suite about to skip is how an
+ // opt-in suite becomes one nobody opts into.
+ return;
+ }
+
+ sshd = new ContainerBuilder("linuxserver/openssh-server:latest")
+ .WithEnvironment("PUID", "1000")
+ .WithEnvironment("PGID", "1000")
+ .WithEnvironment("USER_NAME", SshUsername)
+ .WithEnvironment("USER_PASSWORD", SshPassword)
+ .WithEnvironment("PASSWORD_ACCESS", "true")
+ .WithEnvironment("SUDO_ACCESS", "false")
+ .WithPortBinding(SshPort, assignRandomHostPort: true)
+
+ // A published port is not readiness: the entrypoint generates host keys and rewrites
+ // sshd_config first. Both conditions are needed, and the port check is the one that actually
+ // proves sshd is accepting.
+ .WithWaitStrategy(Wait.ForUnixContainer()
+ .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'"))
+
+ .Build();
+
+ await sshd.StartAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (sshd is not null)
+ {
+ await sshd.DisposeAsync();
+ }
+
+ foreach (var directory in directories.Where(Directory.Exists))
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task TheWholeSlice()
+ {
+ if (unavailable is not null)
+ {
+ Assert.Skip(unavailable);
+ }
+
+ var account = await DevStack.CreateUserAsync(Token);
+ var browser = new ScriptedBrowser(account.Username, account.Password);
+
+ using var connection = await ServerConnection
+ .SignInAsync(new Uri(DevStack.ApiBaseUrl), browser, TimeProvider.System, Token);
+
+ AssertDiscoveredFromTheServer(connection);
+
+ using var laptopCache = await OpenCacheAsync();
+ await EnrollAsync(connection, laptopCache, browser);
+
+ var laptop = await UnlockAsync(laptopCache);
+ await using var laptopSession = laptop;
+
+ var host = BuildHost();
+ var entityId = await laptop.Hosts.CreateAsync(laptop.ActiveVaultId, host, Token);
+
+ var pushed = await laptop.SyncAsync(connection.Sync, Token);
+ pushed.Pushed.ShouldBe(1);
+ pushed.NeedsAttention.ShouldBeFalse();
+
+ await AssertTheServerCannotSeeTheAddressAsync(connection, entityId);
+
+ var seen = await ReadOnASecondMachineAsync(connection, host, entityId);
+
+ await AssertUnlocksOfflineAsync(laptopCache);
+
+ await OpenAShellAsync(seen);
+ }
+
+ // ---- Steps ----
+
+ ///
+ /// The user typed one server URL. Everything about the identity provider — the authority, the client
+ /// id, the scopes — came back from the server, which is the whole onboarding story.
+ ///
+ private static void AssertDiscoveredFromTheServer(ServerConnection connection)
+ {
+ connection.Configuration.Oidc.Authority.ToString()
+ .ShouldStartWith($"{DevStack.KeycloakBaseUrl}/realms/{DevStack.Realm}");
+
+ connection.Configuration.Oidc.ClientId.ShouldBe("dodossh-desktop");
+ connection.Meta.SyncProtocolVersion.ShouldBe(1);
+ connection.Meta.CryptoSpecVersion.ShouldBe(1);
+ }
+
+ private static async Task EnrollAsync(
+ ServerConnection connection,
+ ClientCacheFactory caches,
+ ScriptedBrowser browser)
+ {
+ var provisioner = new AccountProvisioner(
+ connection.Account, connection.KeyBinding, caches, TimeProvider.System, ServerFloorProfile);
+
+ var before = await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token);
+ before.Status.ShouldBe(ProvisionStatus.EnrollmentRequired);
+
+ var enrolled = await provisioner.EnrollAsync(
+ DevStack.ApiBaseUrl, Passphrase, "e2e-laptop", "Personal", Token);
+
+ enrolled.Status.ShouldBe(ProvisionStatus.Ready);
+ enrolled.RecoveryCode.ShouldNotBeNullOrWhiteSpace();
+
+ // Two sign-ins, not one. The second is the identity-provider key binding: an authorization whose
+ // nonce is the key statement's hash, whose ID token the server verified against Keycloak's JWKS
+ // before accepting the key. That is what stops a compromised DodoSSH server fabricating a key for
+ // someone who never enrolled — see ADR 0001 — and it is invisible unless something counts.
+ browser.SignInCount.ShouldBe(
+ 2, "enrollment must obtain an identity-provider signature over the published key");
+ }
+
+ ///
+ /// Asserted against what the server hands back, not against the local mirror. With relay off the
+ /// address stays inside the ciphertext; ADR 0004 is the only reason it would ever be otherwise.
+ ///
+ private static async Task AssertTheServerCannotSeeTheAddressAsync(
+ ServerConnection connection,
+ Guid entityId)
+ {
+ var vaultId = (await connection.Account.GetMeAsync(Token)).Vaults.Single().VaultId;
+
+ var page = await connection.Sync.SyncPullAsync(
+ vaultId, new SyncPullRequest(null, 100, [SyncEntityType.Host]), Token);
+
+ var change = page.Changes.Single(c => c.EntityId == entityId);
+
+ change.PlaintextFields.ShouldNotBeNull();
+ change.PlaintextFields.RelayEnabled.ShouldBeFalse();
+ change.PlaintextFields.Hostname.ShouldBeNull("the address must not leave the payload");
+ change.PlaintextFields.Port.ShouldBeNull();
+
+ // What it does hold is opaque, and it carries its data key as the specification requires.
+ change.Payload.ShouldNotBeNull();
+ change.Payload.WrappedDataKey.ShouldNotBeEmpty();
+ change.Payload.DataKeyId.ShouldNotBe(Guid.Empty);
+ }
+
+ private async Task ReadOnASecondMachineAsync(
+ ServerConnection connection,
+ HostSecret expected,
+ Guid entityId)
+ {
+ using var desktopCache = await OpenCacheAsync();
+
+ var provisioner = new AccountProvisioner(
+ connection.Account, connection.KeyBinding, desktopCache, TimeProvider.System, ServerFloorProfile);
+
+ // Already enrolled, so this only caches what an offline unlock will need.
+ (await provisioner.RefreshAsync(DevStack.ApiBaseUrl, Token)).Status
+ .ShouldBe(ProvisionStatus.Ready);
+
+ var desktop = await UnlockAsync(desktopCache);
+ await using var session = desktop;
+
+ var pulled = await desktop.SyncAsync(connection.Sync, Token);
+ pulled.Pulled.ShouldBe(1);
+
+ var listing = await desktop.Hosts.ListAsync(desktop.ActiveVaultId, Token);
+ var seen = listing.Hosts.ShouldHaveSingleItem();
+
+ seen.EntityId.ShouldBe(entityId);
+ seen.HasUnsyncedChanges.ShouldBeFalse();
+
+ // The decrypted host survived a round trip through a server that could read none of it — including
+ // the directives, which merge per name and therefore have to come back in canonical form.
+ seen.Host.ShouldBe(expected);
+
+ return seen.Host;
+ }
+
+ private static async Task AssertUnlocksOfflineAsync(ClientCacheFactory caches)
+ {
+ // Nothing here touches the network: the salt, the parameters and the wrapped bundle are local.
+ var offline = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
+
+ offline.IsUnlocked.ShouldBeTrue(offline.Message);
+ await offline.Session!.DisposeAsync();
+ }
+
+ ///
+ /// Goes through the real trust-on-first-use path rather than around it. An unknown host key throws, the
+ /// caller pins it and retries — which is what the interface does, and the only way to prove the
+ /// fingerprint a user would be shown is the one the server actually presented.
+ ///
+ private static async Task OpenAShellAsync(HostSecret host)
+ {
+ var knownHosts = new InMemoryKnownHostStore();
+ var factory = new SshNetConnectionFactory(knownHosts);
+
+ var request = new SshConnectionRequest(
+ host.Hostname, host.Port, host.Username!, new SshPasswordCredential(SshPassword));
+
+ try
+ {
+ await using var first = await factory.ConnectAsync(request, Token);
+ Assert.Fail("An unseen host key must not be trusted silently.");
+ }
+ catch (SshHostKeyUnknownException exception)
+ {
+ exception.Presentation.Fingerprint.ShouldStartWith("SHA256:");
+ await knownHosts.TrustAsync(exception.Presentation, Token);
+ }
+
+ await using var connection = await factory.ConnectAsync(request, Token);
+ await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
+
+ await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
+
+ var output = await ReadUntilEchoedAsync(shell, "dodossh-e2e-ok");
+
+ output.ShouldContain("dodossh-e2e-ok");
+ }
+
+ // ---- Helpers ----
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ private static HostSecret BuildHostFor(string hostname, int port) =>
+ new()
+ {
+ Label = "e2e-target",
+ Hostname = hostname,
+ Port = port,
+ Username = SshUsername,
+ Notes = "created by the end-to-end slice",
+ Options = HostOptions.Create([new HostOption("ServerAliveInterval", "30")]),
+ };
+
+ private HostSecret BuildHost() =>
+ BuildHostFor(sshd!.Hostname, sshd.GetMappedPublicPort(SshPort));
+
+ private async Task OpenCacheAsync()
+ {
+ var directory = Path.Combine(Path.GetTempPath(), $"dodossh-e2e-{Guid.CreateVersion7():N}");
+ Directory.CreateDirectory(directory);
+ directories.Add(directory);
+
+ var factory = ClientCacheFactory.ForFile(new ClientPaths(directory).CacheFile);
+
+ try
+ {
+ await factory.MigrateAsync(Token);
+ return factory;
+ }
+ catch
+ {
+ factory.Dispose();
+ throw;
+ }
+ }
+
+ private static async Task UnlockAsync(ClientCacheFactory caches)
+ {
+ var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
+
+ outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
+ return outcome.Session!;
+ }
+
+ ///
+ /// Waits for the marker twice — once as the shell echoes the typed command, once as its output — rather
+ /// than for a fixed time. The login banner arrives first and its length is not something this test
+ /// should have to know.
+ ///
+ private static async Task ReadUntilEchoedAsync(ISshShellSession shell, string marker)
+ {
+ var text = new StringBuilder();
+ var buffer = new byte[8192];
+
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Token);
+ deadline.CancelAfter(TimeSpan.FromSeconds(30));
+
+ while (!deadline.IsCancellationRequested)
+ {
+ var read = await shell.ReadAsync(buffer, deadline.Token);
+
+ if (read == 0)
+ {
+ break;
+ }
+
+ text.Append(Encoding.UTF8.GetString(buffer, 0, read));
+
+ if (Occurrences(text.ToString(), marker) >= 2)
+ {
+ break;
+ }
+ }
+
+ return text.ToString();
+ }
+
+ private static int Occurrences(string text, string marker)
+ {
+ var count = 0;
+ var index = 0;
+
+ while ((index = text.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += marker.Length;
+ }
+
+ return count;
+ }
+}
diff --git a/tests/DodoSSH.SystemTests/ScriptedBrowser.cs b/tests/DodoSSH.SystemTests/ScriptedBrowser.cs
new file mode 100644
index 0000000..7ab0ede
--- /dev/null
+++ b/tests/DodoSSH.SystemTests/ScriptedBrowser.cs
@@ -0,0 +1,207 @@
+using System.Text.RegularExpressions;
+using System.Web;
+using DodoSSH.Client.Auth;
+
+namespace DodoSSH.SystemTests;
+
+///
+/// Signs in to a real Keycloak by driving its login form over HTTP.
+///
+///
+///
+/// Stands in for the system browser, and only for the browser — everything else in the flow is the real
+/// thing. The authorization request, the login form, the redirect back to the loopback listener, the code
+/// exchange and PKCE verification all happen exactly as they would for a person clicking through.
+///
+///
+/// Everything up to the redirect is awaited; the final hop is not. That split is not tidiness.
+/// OidcClient awaits the launcher before it awaits the callback, and the last hop of this flow is a
+/// request to that callback — completing it inline would block on a request nobody is reading yet
+/// and the sign-in would deadlock instead of failing. Awaiting the earlier steps is what makes a rejected
+/// authorization request surface immediately, with Keycloak's own words, rather than as a five-minute wait
+/// for a browser that was never going to arrive.
+///
+///
+internal sealed partial class ScriptedBrowser(string username, string password) : IBrowserLauncher
+{
+ /// How many times a sign-in was driven, so the key binding's second one is visible.
+ internal int SignInCount { get; private set; }
+
+ ///
+ public async Task OpenAsync(Uri url, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(url);
+
+ SignInCount++;
+
+ // Cookies are carried by hand, and automatic handling is off. That is not a shortcut — it is
+ // required, and the reason is worth knowing:
+ //
+ // Keycloak marks its authentication-session cookies `Secure; SameSite=None`, because SameSite=None
+ // is only legal alongside Secure. Over a development stack served on plain HTTP, a
+ // spec-conformant client refuses to store a Secure cookie from an insecure origin, so
+ // CookieContainer silently drops every one of them and the login POST comes back 400 with no
+ // explanation. Browsers do complete this flow, because they treat loopback as a trustworthy
+ // origin and make the exception. Carrying the cookies manually emulates that exception
+ // deliberately, in one visible place, rather than looking like broken cookie handling.
+ var handler = new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false };
+
+ var http = new HttpClient(handler);
+
+ try
+ {
+ var redirect = await SubmitCredentialsAsync(http, url, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Deliberately not awaited: this is the request the loopback listener is waiting for, and it
+ // is only read after this method returns. Ownership of the client passes to the continuation.
+ _ = DeliverAsync(http, handler, redirect, cancellationToken);
+
+ }
+ catch
+ {
+ http.Dispose();
+ handler.Dispose();
+ throw;
+ }
+ }
+
+ /// Fetches the login page and posts the credentials, returning where Keycloak sends us.
+ private async Task SubmitCredentialsAsync(
+ HttpClient http,
+ Uri authorizationUrl,
+ CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, authorizationUrl);
+ using var opened = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ var cookies = CollectCookies(opened);
+
+ var loginPage = await opened.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ var action = ExtractLoginAction(loginPage);
+
+ using var credentials = new FormUrlEncodedContent(
+ new Dictionary(StringComparer.Ordinal)
+ {
+ ["username"] = username,
+ ["password"] = password,
+ ["credentialId"] = string.Empty,
+ });
+
+ using var login = new HttpRequestMessage(HttpMethod.Post, action) { Content = credentials };
+ login.Headers.Add("Cookie", cookies);
+
+ using var posted = await http.SendAsync(login, cancellationToken).ConfigureAwait(false);
+
+ if (posted.Headers.Location is { } redirect)
+ {
+ return redirect;
+ }
+
+ // Keycloak answers a rejected login with another page rather than a header, so the reason is only
+ // in the body. Reporting the status alone would be almost useless.
+ var body = await posted.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+
+ throw new InvalidOperationException(
+ $"Keycloak answered the login form with {(int)posted.StatusCode} and no redirect. It said: "
+ + Summarise(body));
+ }
+
+ ///
+ /// Only the name=value part of each Set-Cookie, which is all a request may send back.
+ ///
+ private static string CollectCookies(HttpResponseMessage response)
+ {
+ if (!response.Headers.TryGetValues("Set-Cookie", out var values))
+ {
+ return string.Empty;
+ }
+
+ var pairs = values
+ .Select(value => value.Split(';', 2)[0].Trim())
+ .Where(pair => pair.Length > 0);
+
+ return string.Join("; ", pairs);
+ }
+
+ private static async Task DeliverAsync(
+ HttpClient http,
+ HttpClientHandler handler,
+ Uri redirect,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var delivered = await http.GetAsync(redirect, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception)
+ {
+ // Nothing useful to do here. If the code never arrives, the sign-in fails on its own timeout
+ // and rethrowing on an unobserved task would take the test host down with it instead.
+ }
+ finally
+ {
+ http.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// The form's action carries the session code and the execution id, so it cannot be constructed — it
+ /// has to be read back out of the page, and it arrives HTML-escaped.
+ ///
+ private static Uri ExtractLoginAction(string loginPage)
+ {
+ var match = LoginFormAction().Match(loginPage);
+
+ if (!match.Success)
+ {
+ throw new InvalidOperationException(
+ "Could not find Keycloak's login form, so the authorization request was rejected before a "
+ + $"login was ever offered. Keycloak said: {Summarise(loginPage)}");
+ }
+
+ return new Uri(HttpUtility.HtmlDecode(match.Groups["action"].Value), UriKind.Absolute);
+ }
+
+ /// Surfaces Keycloak's own error text, which is the only useful part of a rejection page.
+ private static string Summarise(string page)
+ {
+ var messages = ErrorMessage().Matches(page)
+ .Select(match => match.Groups["text"].Value.Trim())
+ .Where(text => text.Length > 0)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (messages.Length > 0)
+ {
+ return string.Join(" / ", messages.Select(text => $"\"{text}\""));
+ }
+
+ var title = PageTitle().Match(page);
+
+ return title.Success
+ ? $"a page titled \"{title.Groups["text"].Value.Trim()}\" with no error text"
+ : $"nothing recognisable, in {page.Length} characters";
+ }
+
+ // A timeout because the input is a page from a server, and an unbounded backtrack on untrusted
+ // input is a hang rather than a failure.
+ [GeneratedRegex(
+ """