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:
2026-07-29 11:37:49 +02:00
parent 49f617b450
commit 1d262b7ccc
20 changed files with 1801 additions and 97 deletions
+57 -57
View File
@@ -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<JsonProblem>();
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<DodoDbContext>();
@@ -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<SyncPushResponse>();
var pushed = await push.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPullResponse>();
var pulled = await pull.Content.ReadContractAsync<SyncPullResponse>();
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<SyncPullResponse>();
new SyncPullRequest(null, null, null))).Content.ReadContractAsync<SyncPullResponse>();
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<SyncPullResponse>();
.Content.ReadContractAsync<SyncPullResponse>();
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<SyncPullResponse>();
.Content.ReadContractAsync<SyncPullResponse>();
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<SyncPullResponse>())!.NextCursor;
var cursor = (await pull.Content.ReadContractAsync<SyncPullResponse>())!.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<JsonProblem>();
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<SyncPushResponse>();
var body = await stale.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPushResponse>();
var body = await replay.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPushResponse>();
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPushResponse>();
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPushResponse>();
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
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<SyncPullResponse>();
var body = await pull.Content.ReadContractAsync<SyncPullResponse>();
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<SyncPushResponse>();
var body = await response.Content.ReadContractAsync<SyncPushResponse>();
body!.Results[0].Status.ShouldBe(SyncOperationStatus.Invalid);
}