Files
DodoSSH/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs
T
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

169 lines
5.9 KiB
C#

using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// The mechanics of a pass: paging, batching, bounds, and what the cursor is allowed to be.
/// </summary>
/// <remarks>
/// Separate from the conflict matrix because the failure modes are different. Here a mistake shows up as
/// a sync that never finishes, or one that quietly stops halfway and reports success.
/// </remarks>
public sealed class SyncEngineTests
{
[Fact]
public async Task APullLargerThanOnePage_ReadsEveryChange()
{
// The server clamps a client's requested limit, so a client that trusted one response to be the
// whole story would silently see part of a vault.
using var harness = await CreateAsync(
new SyncOptions { PullPageSize = 2, MaxOperationsPerPush = 100 });
harness.Server.MaxPullLimit = 2;
for (var index = 0; index < 7; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
await harness.First.SyncAsync();
var report = await harness.Second.SyncAsync();
report.Pulled.ShouldBe(7);
(await harness.Second.ListAsync()).Hosts.Count.ShouldBe(7);
}
[Fact]
public async Task MoreQueuedChangesThanOneBatch_AreAllPushed()
{
using var harness = await CreateAsync(new SyncOptions { MaxOperationsPerPush = 2 });
for (var index = 0; index < 5; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
var report = await harness.First.SyncAsync();
report.Pushed.ShouldBe(5);
harness.Server.RowCount.ShouldBe(5);
// Three rounds of two, so the drain loop genuinely continued rather than stopping at one batch.
harness.Server.PushCount.ShouldBeGreaterThanOrEqualTo(3);
}
[Fact]
public async Task AnExhaustedPushLoop_SaysSoRatherThanPretendingItFinished()
{
// A bound is necessary — each round advances, but against a vault someone else writes to
// continuously a pass could keep finding work. Reporting it is what stops that looking like
// success.
using var harness = await CreateAsync(
new SyncOptions { MaxOperationsPerPush = 1, MaxPushRounds = 2 });
for (var index = 0; index < 5; index++)
{
await harness.First.CreateAsync(Host($"host-{index}"));
}
var report = await harness.First.SyncAsync();
report.RoundsExhausted.ShouldBeTrue();
report.Pushed.ShouldBe(2);
// And the rest is still queued, not lost.
(await harness.First.Outbox.TakeAsync(VaultId, 100, TestContext.Current.CancellationToken))
.Count.ShouldBe(3);
// A further pass picks up where this one stopped.
await harness.First.SyncAsync();
await harness.First.SyncAsync();
harness.Server.RowCount.ShouldBe(5);
}
[Fact]
public async Task TheCursor_IsWhateverTheServerIssued()
{
// Opaque and integrity-tagged. The fake server rejects a cursor it did not mint, so a client that
// computed one would fail here rather than quietly resuming from a position it invented.
using var harness = await CreateAsync();
await harness.First.CreateAsync(Host("prod-db"));
await harness.First.SyncAsync();
var state = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
state.Cursor.ShouldNotBeNull();
state.Cursor.ShouldStartWith("fake-v1:");
}
[Fact]
public async Task AnEmptyPull_DoesNotMoveTheCursor()
{
// If it did, a write landing between this read and the next would be skipped for ever.
using var harness = await CreateAsync();
await harness.First.SyncAsync();
var before = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
await harness.First.SyncAsync();
var after = await harness.First.SyncState.ReadAsync(
VaultId, TestContext.Current.CancellationToken);
after.Cursor.ShouldBe(before.Cursor);
}
[Fact]
public async Task ClockSkew_IsRecordedAndNotActedOn()
{
// Recorded because a user should be able to see it. Not acted on because the merge decides by
// version and retained ancestor — a skewed clock must not be able to pick a winner.
using var harness = await CreateAsync();
harness.Server.Now = TimeProvider.System.GetUtcNow().AddHours(3);
var entityId = await harness.First.CreateAsync(Host("prod-db"));
var report = await harness.First.SyncAsync();
report.ServerTimeSkewMs.ShouldBeGreaterThan(2 * 60 * 60 * 1000);
// The item still round-trips, so nothing downstream depended on the timestamp.
(await harness.First.FindAsync(entityId)).Host.Label.ShouldBe("prod-db");
}
[Fact]
public async Task ASyncWithNothingToDo_TouchesTheServerOnceAndReportsNothing()
{
using var harness = await CreateAsync();
var report = await harness.First.SyncAsync();
report.Pulled.ShouldBe(0);
report.Pushed.ShouldBe(0);
report.NeedsAttention.ShouldBeFalse();
harness.Server.PushCount.ShouldBe(0);
}
[Fact]
public async Task AVaultWithNoUsableGrant_IsReportedRatherThanRead()
{
// A grant awaiting re-wrap after a rekey. The vault is temporarily unreadable and saying so is
// the only honest answer — showing an empty host list would be indistinguishable from an empty
// vault.
using var harness = await CreateAsync();
var unknown = Guid.CreateVersion7();
harness.First.Keyring.CanRead(unknown).ShouldBeFalse();
await Should.ThrowAsync<VaultUnreadableException>(
async () => await harness.First.Hosts.ListAsync(
unknown, TestContext.Current.CancellationToken));
}
}