Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection

This commit is contained in:
jaap-jan committed 2026-10-02 17:18:22 +02:00
1 parent 656a33b4b1
commit 8cb1457ca9
10 files changed
+2438 -2

No files matched your search

@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -612,6 +613,150 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
tombstone.PlaintextFields.ShouldBeNull();
}
// ---- Tombstone collection (ADR 0003) ----
/// <remarks>
/// <para>
/// A tombstone past its retention goes, with its change-log entries — and a client whose cursor is from
/// before it is refused, because it would otherwise never hear of the deletion. A client already past it
/// carries on, and a pull from nothing simply no longer offers the item.
/// </para>
/// <para>
/// The retention is crossed by moving the deletion back in time rather than by waiting it out.
/// </para>
/// </remarks>
[Fact]
public async Task AnExpiredTombstone_IsCollected_AndACursorFromBeforeIt_IsRefused()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
await client.PostContractAsync(PushUrl(vaultId), create);
var before = await PullAsync(client, vaultId, null);
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
]));
var after = await PullAsync(client, vaultId, before.NextCursor);
await AgeTheDeletionAsync(entityId);
await CollectAsync();
var refused = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(before.NextCursor, null, null));
refused.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
(await refused.Content.ReadProblemAsync()).ShouldNotBeNull().Code.ShouldBe(ProblemCodes.InvalidCursor);
(await PullAsync(client, vaultId, after.NextCursor)).Changes.ShouldBeEmpty("a cursor past the deletion is fine");
(await PullAsync(client, vaultId, null)).Changes.ShouldNotContain(change => change.EntityId == entityId);
}
/// <remarks>
/// The other half of the retention: a deletion younger than it stays, entries and all, so a machine that
/// has been away a week still hears of it.
/// </remarks>
[Fact]
public async Task ATombstoneInsideItsRetention_IsKept()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
await client.PostContractAsync(PushUrl(vaultId), create);
var before = await PullAsync(client, vaultId, null);
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
new SyncPushOperation(
Guid.CreateVersion7(), SyncEntityType.Host, entityId, SyncOperation.Delete,
ExpectedVersion: 1, Payload: null, PlaintextFields: null),
]));
await CollectAsync();
var later = await PullAsync(client, vaultId, before.NextCursor);
later.Changes.ShouldHaveSingleItem().Operation.ShouldBe(SyncOperation.Delete);
}
/// <remarks>
/// An item edited many times kept an entry per edit for ever, and a client starting from nothing walked
/// every one of them. After a collection only the newest is left — and a client that held a cursor from
/// between the edits still gets the item, because what it had not seen yet is exactly that newest entry.
/// </remarks>
[Fact]
public async Task SupersededEntries_AreCompactedAway_WithoutCostingAnyClientTheItem()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var create = NewCreateBatch();
var entityId = create.Operations[0].EntityId;
await client.PostContractAsync(PushUrl(vaultId), create);
var between = await PullAsync(client, vaultId, null);
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 1, envelope: [2, 2, 2]),
]));
await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest(
[
NewOperation(entityId, expectedVersion: 2, envelope: [3, 3, 3]),
]));
await CollectAsync();
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
(await database.VaultChanges.CountAsync(c => c.EntityId == entityId, TestContext.Current.CancellationToken))
.ShouldBe(1);
}
(await PullAsync(client, vaultId, between.NextCursor)).Changes.ShouldHaveSingleItem().Version.ShouldBe(3);
(await PullAsync(client, vaultId, null)).Changes.Single(c => c.EntityId == entityId).Version.ShouldBe(3);
}
private static async Task<SyncPullResponse> PullAsync(HttpClient client, Guid vaultId, string? cursor)
{
var response = await client.PostContractAsync(PullUrl(vaultId), new SyncPullRequest(cursor, null, null));
response.StatusCode.ShouldBe(HttpStatusCode.OK);
return (await response.Content.ReadContractAsync<SyncPullResponse>()).ShouldNotBeNull();
}
/// <summary>Moves a deletion back past the retention, which is quicker than waiting ninety days.</summary>
private async Task AgeTheDeletionAsync(Guid entityId)
{
await using var scope = fixture.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var host = await database.Hosts.SingleAsync(h => h.Id == entityId, TestContext.Current.CancellationToken);
host.DeletedAtUtc = host.DeletedAtUtc!.Value.AddDays(-91);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
}
private async Task CollectAsync()
{
await using var scope = fixture.CreateScope();
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>().RunOnceAsync(TestContext.Current.CancellationToken);
}
// ---- Batch limits ----
[Fact]