Files
DodoSSH/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs
T
jaap-jan a878c2b6bb Add the server client and client-side enrollment
A typed client over DodoSSH.Contracts, and the orchestration that turns a
passphrase into an enrolled identity: generate keys, have the identity
provider sign over them, wrap the bundle three ways, create the personal
vault, publish.

Ordering here is forced, not chosen. The secret bundle's AAD binds to the
server-assigned user id, so /me has to be read before anything can be
wrapped -- which is exactly why /me provisions the account and returns its id
even while reporting that enrollment is required. That constraint was
designed into the server earlier; this is the first code that depends on it.

The grant tuple now has a real canonical encoding (crypto.md 7.3) rather
than the placeholder signature I would otherwise have had to invent and then
keep. §7 named the tuple without specifying how to encode it; this fills that
in with the same conventions as 7.1, and the self-grant at enrollment is
already in its final format. The signature covers SHA-256(wrappedKey) rather
than the key, so a verifier can check attribution without holding the vault
key at all.

The most valuable tests are the negative ones about the request body: the
server is meant to be unable to read what it stores, and a refactor that put
a passphrase or a private key into the enrollment request would be invisible
to every other test in the repository. So one asserts the body contains
neither the passphrase, the recovery code, nor any private key in base64 or
hex. Another opens the same bundle three ways -- passphrase, recovery code and
device key -- which is what makes a passphrase change a one-row update.

ClientEnrollment depends on IKeyBindingAuthorizer rather than the whole
OidcClient. It needs exactly one capability, and depending on the full client
would drag discovery and token exchange into every test of key binding.

Two things fixed while building it. The recovery code buffer was sized one
separator short, so every enrollment threw IndexOutOfRange -- caught
immediately because nine of ten tests failed identically. And the crypto
enum collided with Domain.GrantKind in the server, so it is GrantPurpose
there; the numeric values still have to match, which the doc and a test both
say.

448 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:42:56 +02:00

240 lines
8.5 KiB
C#

using System.Net;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api.Tests;
/// <summary>
/// The wire contract: what goes out, what comes back, and how failures surface.
/// </summary>
/// <remarks>
/// The problem-code assertions matter most. Those codes are how a client decides what to do next —
/// <c>enrollment-required</c> means enroll, <c>vault-conflict</c> means merge and retry — so losing one
/// while parsing an error turns an actionable failure into an opaque one.
/// </remarks>
public sealed class DodoSshApiClientTests : IDisposable
{
private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
private readonly StubServer server = new();
private readonly HttpClient http = new();
public DodoSshApiClientTests() => http.BaseAddress = server.BaseUrl;
/// <inheritdoc />
public void Dispose()
{
http.Dispose();
server.Dispose();
}
[Fact]
public async Task Meta_IsFetchedWithoutAToken()
{
// A client has to be able to ask what a server supports before it can authenticate, so this
// one must not require a bearer token.
server.StubMeta(new MetaResponse(
ServerVersion: "1.2.3",
ApiVersions: [1],
SyncProtocolVersion: 1,
CryptoSpecVersion: 1,
Features: ["teams"],
MinClientVersion: null,
MaxOperationsPerPush: 500,
MaxPayloadBytes: 8 * 1024 * 1024,
MaxItemPayloadBytes: 256 * 1024));
var meta = await Client().GetMetaAsync(TestContext.Current.CancellationToken);
meta.ServerVersion.ShouldBe("1.2.3");
meta.MaxOperationsPerPush.ShouldBe(500);
server.LastAuthorization("/api/v1/meta").ShouldBeNull();
}
[Fact]
public async Task Me_CarriesTheBearerToken()
{
server.StubMe(UnenrolledMe());
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
me.EnrollmentRequired.ShouldBeTrue();
server.LastAuthorization("/api/v1/me").ShouldBe("Bearer test-access-token");
}
[Fact]
public async Task Me_RoundTripsVaultSummaries()
{
// Byte arrays through the source-generated serialiser, which is the one thing most likely to
// go wrong silently between the two sides.
var wrappedKey = new byte[] { 1, 2, 3, 4, 5 };
server.StubMe(UnenrolledMe() with
{
EnrollmentRequired = false,
KeyGeneration = 1,
WrappedPrivateKey = [9, 8, 7],
KdfParameters = new KdfParameters("argon2id", [1, 2, 3], 262144, 4, 1),
Vaults =
[
new VaultSummary(
VaultId: VaultId,
Name: "Personal",
IsPersonal: true,
TeamId: null,
KeyGeneration: 1,
Permissions: 31,
WrappedVaultKey: wrappedKey,
RekeyRequired: false),
],
});
var me = await Client().GetMeAsync(TestContext.Current.CancellationToken);
var vault = me.Vaults.ShouldHaveSingleItem();
vault.WrappedVaultKey.ShouldBe(wrappedKey);
vault.KeyGeneration.ShouldBe(1u);
me.WrappedPrivateKey.ShouldBe([9, 8, 7]);
me.KdfParameters!.MemoryKibibytes.ShouldBe(262144);
}
[Fact]
public async Task AProblemResponse_KeepsItsCode()
{
server.StubProblem(
"/api/v1/me", "GET", 403, ProblemCodes.EnrollmentRequired, "Publish an identity key first.");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().GetMeAsync(TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
exception.StatusCode.ShouldBe(HttpStatusCode.Forbidden);
exception.Message.ShouldContain("Publish an identity key first.");
}
[Fact]
public async Task ANonJsonError_StillReportsItsStatus()
{
// A reverse proxy in front of a dead server returns HTML. Losing the status code while trying
// to parse that as ProblemDetails would replace a diagnosable failure with a parse error.
server.StubGatewayError("/api/v1/me", "GET");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().GetMeAsync(TestContext.Current.CancellationToken));
exception.StatusCode.ShouldBe(HttpStatusCode.BadGateway);
exception.Code.ShouldBeNull();
}
[Fact]
public async Task Push_SendsTheBatchAndReturnsPerOperationStatus()
{
// A push succeeds with mixed results on purpose, so one stale item cannot block everything
// else a client queued while offline. Callers must read the statuses rather than trusting
// the 200.
var applied = Guid.CreateVersion7();
var conflicted = Guid.CreateVersion7();
server.StubPush(VaultId, new SyncPushResponse(
Results:
[
new SyncPushResult(applied, SyncOperationStatus.Applied, 1, 10, null, null),
new SyncPushResult(conflicted, SyncOperationStatus.Conflict, 2, 11, null, null),
],
Cursor: "next-cursor"));
var request = new SyncPushRequest(
[
new SyncPushOperation(
applied,
SyncEntityType.Host,
Guid.CreateVersion7(),
SyncOperation.Upsert,
null,
new EncryptedPayload([1, 2, 3], 1, 1),
new SyncPlaintextFields()),
]);
var response = await Client().SyncPushAsync(
VaultId, request, TestContext.Current.CancellationToken);
response.Results.Count.ShouldBe(2);
response.Results[0].Status.ShouldBe(SyncOperationStatus.Applied);
response.Results[1].Status.ShouldBe(SyncOperationStatus.Conflict);
response.Cursor.ShouldBe("next-cursor");
var body = server.LastBody($"/api/v1/vaults/{VaultId}/sync/push");
body.ShouldContain("operations");
body.ShouldContain("Upsert");
}
[Fact]
public async Task Pull_RoundTripsChangesAndTheCursor()
{
var entityId = Guid.CreateVersion7();
server.StubPull(VaultId, new SyncPullResponse(
Changes:
[
new SyncChange(
SyncEntityType.Host,
entityId,
SyncOperation.Upsert,
Version: 1,
ChangeSequence: 5,
Payload: new EncryptedPayload([4, 5, 6], 1, 1),
PlaintextFields: new SyncPlaintextFields(RelayEnabled: false),
UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)),
],
NextCursor: "cursor-2",
HasMore: false,
ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_001),
CurrentKeyGeneration: 1));
var response = await Client().SyncPullAsync(
VaultId,
new SyncPullRequest("cursor-1", null, null),
TestContext.Current.CancellationToken);
var change = response.Changes.ShouldHaveSingleItem();
change.EntityId.ShouldBe(entityId);
change.Payload!.Envelope.ShouldBe([4, 5, 6]);
response.NextCursor.ShouldBe("cursor-2");
response.HasMore.ShouldBeFalse();
server.LastBody($"/api/v1/vaults/{VaultId}/sync/pull").ShouldContain("cursor-1");
}
[Fact]
public async Task AConflictOnPush_SurfacesItsCode()
{
server.StubProblem(
$"/api/v1/vaults/{VaultId}/sync/push",
"POST",
409,
ProblemCodes.VaultConflict,
"Stale version.");
var exception = await Should.ThrowAsync<DodoSshApiException>(async () =>
await Client().SyncPushAsync(
VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken));
exception.Code.ShouldBe(ProblemCodes.VaultConflict);
}
private DodoSshApiClient Client() => new(http, new StubTokenProvider());
private static MeResponse UnenrolledMe() =>
new(
UserId: Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"),
Issuer: "https://idp.example",
Subject: "alice",
Email: "alice@example.com",
DisplayName: "Alice",
EnrollmentRequired: true,
KeyGeneration: null,
WrappedPrivateKey: null,
KdfParameters: null,
Vaults: []);
}