Files
jaap-jan d17a60e7c3 Stop asking the server to delete things it has never seen
Add a host on a laptop with no network, change your mind, delete it: the outbox
holds a tombstone for a row the server has never heard of, the push answers
Invalid, the change is parked, and the user is left looking at a rejected change
for an item they already deleted and a pending count that will never reach zero.
It applies to all four item types, because they all go through the one generic
repository — the known-host path is only the likeliest way to meet it, since
trust is pinned by connecting and withdrawn from the host editor.

DeleteAsync now drops the queued create instead, when the server cannot be
holding the item. A null expected version means the row is a create — including
a create that has since been edited, because coalescing keeps the original
expected version — so there is no server row and no mirror row, and dropping the
queued change makes the item genuinely gone.

The attempt count is what makes that safe rather than merely convenient. Nothing
sent cannot have landed. A parked row cannot have landed either, because parking
is what the pusher does when the server has refused, so the refusal is the
evidence — and a parked create that the user then deletes could not be got rid
of at all before this: the tombstone replacing it was parked in its turn. What
is left is a create that went out and whose answer was never seen. That one
still gets a tombstone, because the server may be holding the item and a local
drop would strand it there for ever. A refused tombstone is recoverable; an
orphan nobody can see and nobody can delete is not.

Eight tests, and the interesting half is the other direction. A repository that
quietly dropped tombstones would pass a suite written only around the bug and
would lose data on every machine but the one that pressed the button.

Which is not hypothetical, because the mutation pass found exactly that hole in
the first draft of these tests. Removing the expected-version guard left every
test passing: after a sync there is no queued row at all, so deleting a synced
item never reaches the shortcut and proves nothing about it. The way to hold an
unpushed Upsert over an item the server holds is to edit it offline, and
EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone is the test
that was missing. Without the guard it deletes the item here, leaves it on the
server, and the next pull brings it back.

Three mutations, all caught now: removing the shortcut (5 tests), removing the
expected-version guard (1), removing the attempt-count guard (1). The
Upsert check itself is conservative rather than load-bearing — a queued Delete
with no expected version is not reachable from the interface, and completing one
locally would discard a tombstone that might be needed, so it stays and is not
independently covered.

106 tests green in Client.Sync, 8 of them new. Zero warnings, format clean.
2026-07-30 17:15:51 +02:00

202 lines
8.4 KiB
C#

using DodoSSH.Contracts;
using static DodoSSH.Client.Sync.Tests.SyncHarness;
namespace DodoSSH.Client.Sync.Tests;
/// <summary>
/// Deleting something the server has never heard of.
/// </summary>
/// <remarks>
/// <para>
/// Every delete used to queue a tombstone, which is right for an item the server holds and wrong for one it
/// does not: it answers <c>Invalid</c>, the change is parked, and the user is left looking at a rejected
/// change for an item they already deleted and a pending count that never reaches zero. Adding a host on a
/// laptop with no network and changing your mind is enough to produce it.
/// </para>
/// <para>
/// The interesting half of these tests is the other direction — that the shortcut does not swallow a real
/// delete. A repository that quietly dropped tombstones would pass a test suite written only around the bug
/// and would lose data on every machine but the one that pressed the button.
/// </para>
/// </remarks>
public sealed class UnpushedDeleteTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task CreatingAndDeletingBeforeAnySync_QueuesNothingAndPushesNothing()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("scratch"));
await laptop.DeleteAsync(entityId);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
(await laptop.ListAsync()).Items.ShouldBeEmpty();
var report = await laptop.SyncAsync();
report.Parked.ShouldBe(0, "there is nothing for the server to refuse");
report.Pushed.ShouldBe(0);
harness.Server.RowCount.ShouldBe(0);
}
[Fact]
public async Task CreatingEditingAndDeletingBeforeAnySync_AlsoQueuesNothing()
{
// Coalescing keeps the original expected version, so a create that has since been edited is still a
// create. Reading the operation alone would see an Upsert with an ancestor and take it for an edit.
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("scratch"));
await laptop.UpdateAsync(entityId, Host("scratch", port: 2222));
await laptop.DeleteAsync(entityId);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
}
[Fact]
public async Task ACreateTheServerRefused_StopsBeingParkedWhenItIsDeleted()
{
// Parking is the pusher's record that the server said no, so a parked create is one the server
// provably does not hold. Before this, deleting it replaced a parked create with a tombstone that
// would be parked in its turn — the item could not be got rid of at all.
using var harness = await CreateAsync();
var laptop = harness.First;
harness.Server.DenyWrites = true;
var entityId = await laptop.CreateAsync(Host("scratch"));
var refused = await laptop.SyncAsync();
refused.Parked.ShouldBe(1);
harness.Server.RowCount.ShouldBe(0);
harness.Server.DenyWrites = false;
await laptop.DeleteAsync(entityId);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
var after = await laptop.SyncAsync();
after.Parked.ShouldBe(0);
}
[Fact]
public async Task DeletingAnItemTheServerHolds_StillQueuesATombstone()
{
// The guard on the whole change. A shortcut that fired here would delete the item on this machine
// and nowhere else, and the next pull would bring it back.
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("prod-db"));
await laptop.SyncAsync();
harness.Server.RowCount.ShouldBe(1);
await laptop.DeleteAsync(entityId);
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
queued.Operation.ShouldBe(SyncOperation.Delete);
await laptop.SyncAsync();
harness.Server.RowCount.ShouldBe(0);
(await laptop.ListAsync()).Items.ShouldBeEmpty();
}
/// <remarks>
/// The case the obvious test misses, and the one that would lose data. After a sync there is no queued
/// row at all, so deleting a synced item never reaches the shortcut and proves nothing about it; the way
/// to hold an unpushed <em>Upsert</em> over an item the server holds is to edit it offline. Dropping that
/// row would delete the item here, leave it on the server, and bring it back on the next pull. Found by
/// mutation — removing the expected-version guard left every other test in this file passing.
/// </remarks>
[Fact]
public async Task EditingASyncedItemOfflineAndThenDeletingIt_StillQueuesATombstone()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("prod-db"));
await laptop.SyncAsync();
// An unpushed edit: a queued Upsert whose expected version says the server already has this.
await laptop.UpdateAsync(entityId, Host("prod-db", port: 2222));
var edit = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
edit.Operation.ShouldBe(SyncOperation.Upsert);
edit.ExpectedVersion.ShouldNotBeNull();
await laptop.DeleteAsync(entityId);
var tombstone = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
tombstone.Operation.ShouldBe(SyncOperation.Delete);
await laptop.SyncAsync();
harness.Server.RowCount.ShouldBe(0, "the deletion has to reach the server, not just this machine");
(await laptop.ListAsync()).Items.ShouldBeEmpty();
}
[Fact]
public async Task ACreateThatWentOutAndWasNeverAnswered_StillQueuesATombstone()
{
// The ambiguous case, and the reason the attempt count is part of the test rather than just the
// expected version. A create that was dispatched may have been applied by a server whose answer never
// arrived. Dropping it locally would strand a row nobody can see and nobody can delete; a tombstone
// that turns out to be unnecessary is refused once and can be dealt with.
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("in-flight"));
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
// Sent, with the answer lost. Marked directly because the engine does this immediately before the
// call it may not survive, and reproducing a dropped response through the fake server would test the
// fake rather than the repository.
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
await laptop.DeleteAsync(entityId);
var tombstone = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
tombstone.Operation.ShouldBe(SyncOperation.Delete);
}
/// <remarks>
/// The path most likely to meet this in practice, which is why it gets its own test rather than trusting
/// the generic repository: trust is pinned by connecting and withdrawn from the host editor, so trusting a
/// rebuilt server and then withdrawing it before the next sync is a minute's work rather than a contrived
/// sequence. It is the same code as the host case and would be caught by the same mutation — what this
/// pins is that the generic repository really is what all four types go through.
/// </remarks>
[Fact]
public async Task TheSameHoldsForAPinTrustedAndForgottenOffline()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateKnownHostAsync(KnownHost());
await laptop.DeleteKnownHostAsync(entityId);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
(await laptop.ListKnownHostsAsync()).Items.ShouldBeEmpty();
}
[Fact]
public async Task TheSameHoldsForAKeyAddedAndRemovedOffline()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateKeyAsync(Key("scratch"));
await laptop.DeleteKeyAsync(entityId);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
(await laptop.ListKeysAsync()).Items.ShouldBeEmpty();
}
}