Files
DodoSSH/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +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], [7, 7], Guid.CreateVersion7(), 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], [8, 8], Guid.CreateVersion7(), 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: []);
}