Public Access
Run M1's end-to-end slice, and fix the two bugs it found
The whole vertical slice now runs against a real Keycloak, a real API, a real PostgreSQL and a real sshd: sign in through the browser flow, enroll with the identity-provider key binding, unlock, create a host, sync it, read it back on a second machine, unlock again with no network, accept an unseen host key, and open an interactive shell. Opt-in, because it needs the development stack; skipped with a message naming the commands. It found two bugs on its first run, and both are the same class: two sides of a stub agreeing with each other about something the specification never said. **The API never applied DodoSshJsonContext to its HTTP JSON options.** Minimal APIs therefore used the framework's web defaults, which write an enum as a number. Every request DTO carrying one failed to bind against a client writing the specified string form — which is the entire sync surface, unreachable from the real client, with a 400 naming only the parameter. The documented guarantee that request bodies reject unmapped members was likewise not in effect anywhere. Nothing caught it because the API tests posted with PostAsJsonAsync's defaults, so they and the server had independently settled on integers. Those tests now serialise through the contract, which is the deeper fix: removing the new configuration fails 13 of them. Copying settings into options a host owns is itself the hazard the context warns about, so ApplyTo lives beside the settings it mirrors and ApplyToTests pins the transformation, including that inserting the resolver leaves the caller's own in place. **The realm registered a loopback redirect URI Keycloak rejects.** `http://127.0.0.1:*/callback` looks more explicit than the RFC 8252 form and is broken: Keycloak's wildcards are trailing-only, so the `*` parses as a literal port and every authorization request came back "Invalid parameter: redirect_uri". Providers ignore the port for loopback hosts, which is the whole mechanism, so the correct registration is `http://127.0.0.1/callback` — path pinned, port free. The value the server advertises through the discovery document said the same wrong thing and now says the right one. Two smaller things, both documented in docs/platform-flags.md: - --import-realm skips a realm that already exists, so editing the realm file and restarting Keycloak changes nothing and serves stale configuration. The container has to be recreated. The compose comment claimed the opposite. - Keycloak marks its session cookies Secure even over plain HTTP, because SameSite=None requires it. A spec-conformant client drops them and the login POST answers 400 with no message; browsers complete the flow only because they exempt loopback. Harmless for the product, fatal for automation, so ScriptedBrowser carries the cookies by hand and says why. Also: the server enforces a 64 MiB floor on the passphrase KDF, so this suite cannot use the 8 MiB profile the other client suites take for speed. Those only get away with it because their in-memory servers have no policy — worth knowing rather than rediscovering. 638 tests. The solution-wide run stays green with the stack down: exit code 8 means "no tests ran", which the platform reports as failure, so the opt-in project ignores exactly that code.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Sends and reads request bodies the way a real client does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not a convenience. <c>PostAsJsonAsync</c>'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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything here goes through <see cref="DodoSshJsonContext"/> for that reason. If the server's JSON
|
||||
/// configuration regresses, these tests are the ones that must fail.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ContractJson
|
||||
{
|
||||
private static JsonSerializerOptions Options => DodoSshJsonContext.ResponseOptions;
|
||||
|
||||
internal static Task<HttpResponseMessage> PostContractAsync<T>(
|
||||
this HttpClient client,
|
||||
string url,
|
||||
T value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
|
||||
return client.PostAsJsonAsync(url, value, Options, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
internal static Task<T?> ReadContractAsync<T>(this HttpContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
return content.ReadFromJsonAsync<T>(Options, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Reads an RFC 9457 problem body.</summary>
|
||||
/// <remarks>
|
||||
/// Deliberately <em>not</em> through the contract options. Problem details are written by the framework
|
||||
/// and are not part of <see cref="DodoSshJsonContext"/>'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 <c>code</c> extension belongs to us.
|
||||
/// </remarks>
|
||||
internal static Task<JsonProblem?> ReadProblemAsync(this HttpContent content)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
return content.ReadFromJsonAsync<JsonProblem>(TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -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<SyncPushResponse>();
|
||||
var body = await push.Content.ReadContractAsync<SyncPushResponse>();
|
||||
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<EnrollmentResponse>(responses.Length);
|
||||
foreach (var response in responses)
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<EnrollmentResponse>();
|
||||
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
||||
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<MeResponse>();
|
||||
var me = await response.Content.ReadContractAsync<MeResponse>();
|
||||
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<EnrollmentResponse>();
|
||||
var body = await response.Content.ReadContractAsync<EnrollmentResponse>();
|
||||
body.ShouldNotBeNull();
|
||||
return body;
|
||||
}
|
||||
@@ -884,7 +884,7 @@ public sealed class IdentityEndpointTests(ApiFixture fixture)
|
||||
{
|
||||
response.StatusCode.ShouldBe(expectedStatus);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<JsonProblem>();
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
problem.Code.ShouldBe(expectedCode);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user