fix(account): close deletion cache gap
CI and Deploy / test (pull_request) Successful in 5m22s
CI and Deploy / deploy (pull_request) Has been skipped

Require authenticated sidecar cache purge before a deletion can complete and keep failures retryable. Mount tombstones outside restored application data while leaving deletion disabled by default.
This commit is contained in:
cesnimda
2026-08-15 19:40:07 +02:00
parent c0e190d5b5
commit 7185491a05
14 changed files with 134 additions and 20 deletions
+39 -3
View File
@@ -152,6 +152,35 @@ public sealed class AccountDeletionTests
Assert.Equal(1, await fixture.Service.ProcessPendingAsync(CancellationToken.None));
Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
Assert.Equal(2, (await fixture.Tombstones.ReadAsync(CancellationToken.None)).Count);
fixture.CachePurger.Verify(item => item.PurgeAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
});
}
[Fact]
public async Task Sidecar_cache_failure_keeps_deleted_account_retryable_until_purge_succeeds()
{
await InFixtureAsync(enabled: true, async fixture =>
{
fixture.Db.Users.Add(User("owner"));
await fixture.Db.SaveChangesAsync();
fixture.CachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("synthetic sidecar outage"));
var accepted = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
Assert.NotNull(accepted);
Assert.False(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None));
fixture.Db.ChangeTracker.Clear();
Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == "owner"));
var retry = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == accepted.RequestId);
Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, retry.Status);
Assert.Equal(AccountDeletionStages.PurgingFiles, retry.Stage);
Assert.Empty(await fixture.Tombstones.ReadAsync(CancellationToken.None));
fixture.CachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None));
Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None));
});
}
@@ -270,8 +299,10 @@ public sealed class AccountDeletionTests
using var cache = new MemoryCache(new MemoryCacheOptions());
var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths));
var tombstones = new AccountDeletionTombstoneStore(paths);
var service = new AccountDeletionService(db, inventory, tombstones, configuration, cache, TimeProvider.System, NullLogger<AccountDeletionService>.Instance);
await test(new Fixture(db, paths, tombstones, service));
var cachePurger = new Mock<IAiSidecarCachePurger>();
cachePurger.Setup(item => item.PurgeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var service = new AccountDeletionService(db, inventory, tombstones, cachePurger.Object, configuration, cache, TimeProvider.System, NullLogger<AccountDeletionService>.Instance);
await test(new Fixture(db, paths, tombstones, cachePurger, service));
}
finally
{
@@ -280,5 +311,10 @@ public sealed class AccountDeletionTests
}
}
private sealed record Fixture(JobTrackerContext Db, AppPaths Paths, AccountDeletionTombstoneStore Tombstones, AccountDeletionService Service);
private sealed record Fixture(
JobTrackerContext Db,
AppPaths Paths,
AccountDeletionTombstoneStore Tombstones,
Mock<IAiSidecarCachePurger> CachePurger,
AccountDeletionService Service);
}