Files
DodoSSH/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs
jaap-jan 9608d73747
ci / build and test (push) Failing after 2s
Come back from a sync position the server will not accept
"The server returned 400: The sync cursor is not valid for this vault. Resync
from the beginning." told the user exactly what to do and gave them no way to
do it. The cursor is the only thing a pull sends, so the refusal was permanent:
the next pass read the same stored cursor and was told the same thing, once a
minute, for ever. And because the pull runs first, the exception ended the pass
before it reached the outbox — so the vault stopped receiving other machines'
changes and stopped sending its own. A machine that met this went quietly
read-only until somebody deleted its cache.

The engine now does what the message asks. A pull refused with the
invalid-cursor problem code — the code, never the prose, which is free to
change — drops this vault's position, writes that down, and reads the log again
from the beginning. The restarted request carries no cursor, which is the one
position a server cannot reject, so the retry cannot loop; a refusal of that is
rethrown rather than retried, and a restart is allowed once per pull. The
position is saved before the replay starts, so a process that dies halfway
through begins the next one from the beginning too rather than meeting the same
refusal again.

The mirror is deliberately kept. Replaying rewrites every row the server still
has and applying a change is a blind overwrite, so the re-pull repairs the
mirror on its way past; clearing it first would claim more than the evidence
supports — the position was refused, not the contents — and would leave a
machine that lost its connection mid-replay with less than it started with.
That leaves one gap, named in the remarks rather than left to be discovered:
once tombstone collection exists, a replay stops carrying deletions older than
the retention window.

None of the causes are the user's doing — a rotated cursor signing key, a vault
served from a restored database, a cache copied between machines — so nothing
asks them to decide anything. The report carries ResyncedFromStart and the
status line says the position was not recognised and the vault was read again.
It is kept out of NeedsAttention, because nothing is outstanding, but the
background pass breaks its usual silence for it: a sync that pulled the whole
vault on a day nobody changed anything otherwise reads as a fault.

The fake server grew a switch that refuses cursors the way a rotated signing
key does, including ones it minted itself. Three cases: the vault is re-read
and the change on the far side of the refused position arrives; the edits
waiting in the outbox are still pushed in that same pass, which is the half
that made this worth recovering from rather than merely reporting; and a server
that refuses the beginning itself is surfaced instead of replayed against.

dotnet build is clean at zero warnings, dotnet format is clean, and the sync
and app suites pass — 109 and 101.
2026-07-31 11:32:14 +02:00

235 lines
8.4 KiB
C#

using DodoSSH.Client.Api;
using DodoSSH.Contracts;
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()).Items.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 ARefusedCursor_ReadsTheVaultAgainFromTheBeginning()
{
// The server's cursor signing key was rotated, or the vault is being served from a restored
// database. The stored position is refused for good, so a pass that only reported the 400 would
// leave this machine frozen at it — asking once a minute, for ever, and being told the same thing.
using var harness = await CreateAsync();
await harness.First.SyncAsync();
var entityId = await harness.Second.CreateAsync(Host("prod-db"));
await harness.Second.SyncAsync();
harness.Server.RefuseCursors = true;
var report = await harness.First.SyncAsync();
report.ResyncedFromStart.ShouldBeTrue();
report.Pulled.ShouldBe(1);
// And the point of all of it: the change that was on the far side of the refused position is here.
(await harness.First.FindAsync(entityId)).Secret.Label.ShouldBe("prod-db");
}
[Fact]
public async Task ARefusedCursor_DoesNotStrandWhatIsWaitingToBePushed()
{
// The half that made this worth recovering from rather than merely reporting. The pull runs first,
// so a pass that gave up on the refusal never reached the outbox at all: every edit made on this
// machine stayed queued behind a position the server was never going to accept again.
using var harness = await CreateAsync();
await harness.First.SyncAsync();
var entityId = await harness.First.CreateAsync(Host("prod-db"));
harness.Server.RefuseCursors = true;
var report = await harness.First.SyncAsync();
report.ResyncedFromStart.ShouldBeTrue();
report.Pushed.ShouldBe(1);
harness.Server.Find(entityId).ShouldNotBeNull();
}
[Fact]
public async Task ARefusalOfTheBeginningItself_IsReportedRatherThanRetried()
{
// "From the beginning" is the one position a client is allowed to ask for, so a server that
// refuses it is one this code cannot reason about. Saying so beats replaying the log against it
// until a page bound runs out.
using var harness = await CreateAsync();
await harness.First.SyncAsync();
harness.Server.RefuseCursors = true;
harness.Server.RefuseEvenTheBeginning = true;
var failure = await Should.ThrowAsync<DodoSshApiException>(
async () => await harness.First.SyncAsync());
failure.Code.ShouldBe(ProblemCodes.InvalidCursor);
}
[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)).Secret.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));
}
}