Public Access
Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection
This commit is contained in:
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]
|
||||
|
||||
Reference in new issue
Block a user