7185491a05
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.
321 lines
18 KiB
C#
321 lines
18 KiB
C#
using System.Security.Claims;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class AccountDeletionTests
|
|
{
|
|
[Fact]
|
|
public async Task Disabled_lifecycle_never_accepts_a_deletion_request()
|
|
{
|
|
await InFixtureAsync(enabled: false, async fixture =>
|
|
{
|
|
fixture.Db.Users.Add(User("owner"));
|
|
await fixture.Db.SaveChangesAsync();
|
|
|
|
Assert.Null(await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None));
|
|
Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
|
|
Assert.Empty(await fixture.Db.AccountDeletionRequests.ToListAsync());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Request_is_idempotent_and_immediately_locks_down_the_account()
|
|
{
|
|
await InFixtureAsync(enabled: true, async fixture =>
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
fixture.Db.Users.AddRange(User("owner"), User("other"));
|
|
fixture.Db.CvVariants.Add(new CvVariant
|
|
{
|
|
OwnerUserId = "owner", PublicSlug = "public-owner", Name = "Owner CV", IsPublic = true,
|
|
CreatedAtUtc = now, UpdatedAtUtc = now,
|
|
});
|
|
fixture.Db.UserSessions.Add(new UserSession
|
|
{
|
|
Id = "session", UserId = "owner", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
|
|
});
|
|
fixture.Db.TrustedDevices.Add(new TrustedDevice
|
|
{
|
|
UserId = "owner", TokenHash = "hash", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddDays(1),
|
|
});
|
|
var queued = Operation("owner", OperationStatuses.Queued);
|
|
var running = Operation("owner", OperationStatuses.Running);
|
|
fixture.Db.UserOperations.AddRange(queued, running);
|
|
await fixture.Db.SaveChangesAsync();
|
|
|
|
var first = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
|
|
var second = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.Equal(first!.RequestId, second!.RequestId);
|
|
Assert.Single(await fixture.Db.AccountDeletionRequests.ToListAsync());
|
|
var user = await fixture.Db.Users.SingleAsync(item => item.Id == "owner");
|
|
Assert.Equal(AccountDeletionStatuses.Pending, user.DeletionStatus);
|
|
Assert.NotNull(user.DeletionRequestedAtUtc);
|
|
Assert.False((await fixture.Db.CvVariants.IgnoreQueryFilters().SingleAsync()).IsPublic);
|
|
Assert.NotNull((await fixture.Db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc);
|
|
Assert.Empty(await fixture.Db.TrustedDevices.IgnoreQueryFilters().ToListAsync());
|
|
Assert.Equal(OperationStatuses.Cancelled, (await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == queued.Id)).Status);
|
|
Assert.NotNull((await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == running.Id)).CancellationRequestedAtUtc);
|
|
Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync(item => item.Id == "other")).DeletionStatus);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Completed_deletion_is_owner_isolated_purges_files_and_replays_after_restore()
|
|
{
|
|
await InFixtureAsync(enabled: true, async fixture =>
|
|
{
|
|
var owner = User("owner");
|
|
var other = User("other");
|
|
fixture.Db.Users.AddRange(owner, other);
|
|
var ownerCompany = new Company { OwnerUserId = owner.Id, Name = "Owner company" };
|
|
var otherCompany = new Company { OwnerUserId = other.Id, Name = "Other company" };
|
|
fixture.Db.Companies.AddRange(ownerCompany, otherCompany);
|
|
await fixture.Db.SaveChangesAsync();
|
|
var ownerApplication = new JobApplication { OwnerUserId = owner.Id, CompanyId = ownerCompany.Id, JobTitle = "Owner role" };
|
|
var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = otherCompany.Id, JobTitle = "Other role" };
|
|
fixture.Db.JobApplications.AddRange(ownerApplication, otherApplication);
|
|
await fixture.Db.SaveChangesAsync();
|
|
|
|
var attachmentPath = Path.Combine(fixture.Paths.AttachmentsRoot, ownerApplication.Id.ToString(), "owner.txt");
|
|
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
|
|
await File.WriteAllTextAsync(attachmentPath, "owner attachment");
|
|
fixture.Db.Attachments.Add(new Attachment
|
|
{
|
|
JobApplicationId = ownerApplication.Id, FileName = "owner.txt", FilePath = attachmentPath,
|
|
FileType = "text/plain", FileSize = new FileInfo(attachmentPath).Length,
|
|
});
|
|
var artifactRoot = Path.Combine(fixture.Paths.CvArtifactsRoot, AppPaths.GetOwnerStorageKey(owner.Id));
|
|
Directory.CreateDirectory(artifactRoot);
|
|
var artifactPath = Path.Combine(artifactRoot, "owner-cv.txt");
|
|
await File.WriteAllTextAsync(artifactPath, "owner cv");
|
|
fixture.Db.CvUploadArtifacts.Add(new CvUploadArtifact
|
|
{
|
|
OwnerUserId = owner.Id, OriginalFileName = "owner-cv.txt", StoredFileName = "owner-cv.txt",
|
|
MimeType = "text/plain", ByteSize = new FileInfo(artifactPath).Length, Sha256 = "synthetic", StoragePath = artifactPath,
|
|
});
|
|
var otherOperation = Operation(other.Id, OperationStatuses.Queued);
|
|
fixture.Db.UserOperations.Add(otherOperation);
|
|
await fixture.Db.SaveChangesAsync();
|
|
var generatedRoot = Path.Combine(fixture.Paths.GetOwnerCvExportsRoot(owner.Id), "20260815");
|
|
Directory.CreateDirectory(generatedRoot);
|
|
var generatedPath = Path.Combine(generatedRoot, "owner.pdf");
|
|
await File.WriteAllTextAsync(generatedPath, "owner pdf");
|
|
var accountExportRoot = fixture.Paths.GetOwnerAccountExportsRoot(owner.Id);
|
|
Directory.CreateDirectory(accountExportRoot);
|
|
var accountExportPath = Path.Combine(accountExportRoot, "previous.zip");
|
|
await File.WriteAllTextAsync(accountExportPath, "previous account export");
|
|
|
|
var accepted = await fixture.Service.RequestAsync(owner.Id, owner.Id, CancellationToken.None);
|
|
Assert.NotNull(accepted);
|
|
Assert.True(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None));
|
|
|
|
Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id));
|
|
Assert.True(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == other.Id));
|
|
Assert.False(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
|
|
Assert.True(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
|
|
Assert.False(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id));
|
|
Assert.True(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id));
|
|
Assert.True(await fixture.Db.UserOperations.IgnoreQueryFilters().AnyAsync(item => item.Id == otherOperation.Id));
|
|
Assert.False(File.Exists(attachmentPath));
|
|
Assert.False(File.Exists(artifactPath));
|
|
Assert.False(File.Exists(generatedPath));
|
|
Assert.False(File.Exists(accountExportPath));
|
|
var request = await fixture.Db.AccountDeletionRequests.Include(item => item.Files).SingleAsync(item => item.Id == accepted.RequestId);
|
|
Assert.Equal(AccountDeletionRequestStatuses.Completed, request.Status);
|
|
Assert.Equal(AccountDeletionStages.Completed, request.Stage);
|
|
Assert.NotEmpty(request.Files);
|
|
Assert.All(request.Files, item => Assert.Equal("purged", item.Status));
|
|
Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None));
|
|
Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None));
|
|
|
|
fixture.Db.Users.Add(User(owner.Id));
|
|
await fixture.Db.SaveChangesAsync();
|
|
Assert.Equal(1, await fixture.Service.StageRestoredAccountsAsync(CancellationToken.None));
|
|
fixture.Db.ChangeTracker.Clear();
|
|
Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.AsNoTracking().SingleAsync(item => item.Id == owner.Id)).DeletionStatus);
|
|
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));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Quarantine_failure_restores_files_and_never_starts_database_deletion()
|
|
{
|
|
await InFixtureAsync(enabled: true, async fixture =>
|
|
{
|
|
fixture.Db.Users.Add(User("owner"));
|
|
var source = Path.Combine(fixture.Paths.AttachmentsRoot, "source.txt");
|
|
await File.WriteAllTextAsync(source, "must survive");
|
|
var invalidTarget = source + ".quarantine";
|
|
Directory.CreateDirectory(invalidTarget);
|
|
var request = new AccountDeletionRequest
|
|
{
|
|
Id = Guid.NewGuid(), OwnerUserId = "owner", OwnerKey = AppPaths.GetOwnerStorageKey("owner"),
|
|
RequestedByUserId = "owner", Status = AccountDeletionRequestStatuses.Pending,
|
|
Stage = AccountDeletionStages.QuarantiningFiles, RequestedAtUtc = DateTimeOffset.UtcNow,
|
|
Files =
|
|
[
|
|
new AccountDeletionFile
|
|
{
|
|
Category = "attachment", OriginalPath = source, QuarantinePath = invalidTarget,
|
|
Status = "planned", ByteSize = new FileInfo(source).Length, Sha256 = "synthetic",
|
|
},
|
|
],
|
|
};
|
|
fixture.Db.AccountDeletionRequests.Add(request);
|
|
await fixture.Db.SaveChangesAsync();
|
|
|
|
Assert.False(await fixture.Service.ProcessAsync(request.Id, CancellationToken.None));
|
|
fixture.Db.ChangeTracker.Clear();
|
|
var failed = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == request.Id);
|
|
Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, failed.Status);
|
|
Assert.Equal(AccountDeletionStages.QuarantiningFiles, failed.Stage);
|
|
Assert.True(File.Exists(source));
|
|
Assert.True(await fixture.Db.Users.AnyAsync(item => item.Id == "owner"));
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Self_service_requires_exact_phrase_and_recent_sign_in()
|
|
{
|
|
await InFixtureAsync(enabled: true, async fixture =>
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
fixture.Db.Users.Add(User("owner"));
|
|
fixture.Db.UserSessions.Add(new UserSession
|
|
{
|
|
Id = "session", UserId = "owner", CreatedAtUtc = now.AddMinutes(-16),
|
|
LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1),
|
|
});
|
|
await fixture.Db.SaveChangesAsync();
|
|
var controller = Controller(fixture, "owner", "session");
|
|
|
|
Assert.IsType<BadRequestObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE wrong@example.test"), CancellationToken.None));
|
|
var stale = Assert.IsType<ObjectResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
|
|
Assert.Equal(StatusCodes.Status403Forbidden, stale.StatusCode);
|
|
|
|
var session = await fixture.Db.UserSessions.SingleAsync();
|
|
session.CreatedAtUtc = DateTimeOffset.UtcNow;
|
|
await fixture.Db.SaveChangesAsync();
|
|
var accepted = Assert.IsType<AcceptedResult>(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None));
|
|
Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode);
|
|
Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.SingleAsync()).DeletionStatus);
|
|
});
|
|
}
|
|
|
|
private static ApplicationUser User(string id) => new()
|
|
{
|
|
Id = id, UserName = $"{id}@example.test", NormalizedUserName = $"{id.ToUpperInvariant()}@EXAMPLE.TEST",
|
|
Email = $"{id}@example.test", NormalizedEmail = $"{id.ToUpperInvariant()}@EXAMPLE.TEST", EmailConfirmed = true,
|
|
};
|
|
|
|
private static UserOperation Operation(string owner, string status) => new()
|
|
{
|
|
Id = Guid.NewGuid(), OwnerUserId = owner, TaskType = "synthetic", IdempotencyKey = Guid.NewGuid().ToString("N"),
|
|
Status = status, CreatedAtUtc = DateTime.UtcNow, AvailableAtUtc = DateTime.UtcNow,
|
|
};
|
|
|
|
private static AccountLifecycleController Controller(Fixture fixture, string userId, string sessionId)
|
|
{
|
|
var controller = new AccountLifecycleController(fixture.Db, fixture.Service, TimeProvider.System)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
|
};
|
|
controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
|
|
[
|
|
new Claim(ClaimTypes.NameIdentifier, userId),
|
|
new Claim("sid", sessionId),
|
|
], "local"));
|
|
return controller;
|
|
}
|
|
|
|
private static async Task InFixtureAsync(bool enabled, Func<Fixture, Task> test)
|
|
{
|
|
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-deletion-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(root);
|
|
try
|
|
{
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Data:Root"] = root,
|
|
["AccountLifecycle:DeletionEnabled"] = enabled.ToString(),
|
|
}).Build();
|
|
var environment = new Mock<IHostEnvironment>();
|
|
environment.SetupGet(item => item.ContentRootPath).Returns(root);
|
|
var paths = new AppPaths(configuration, environment.Object);
|
|
var currentUser = new Mock<ICurrentUserService>();
|
|
currentUser.SetupGet(item => item.UserId).Returns("owner");
|
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
|
.UseSqlite($"Data Source={Path.Combine(root, "deletion-tests.db")}")
|
|
.Options;
|
|
await using var db = new JobTrackerContext(options, currentUser.Object);
|
|
await db.Database.EnsureCreatedAsync();
|
|
using var cache = new MemoryCache(new MemoryCacheOptions());
|
|
var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths));
|
|
var tombstones = new AccountDeletionTombstoneStore(paths);
|
|
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
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|
|
|
|
private sealed record Fixture(
|
|
JobTrackerContext Db,
|
|
AppPaths Paths,
|
|
AccountDeletionTombstoneStore Tombstones,
|
|
Mock<IAiSidecarCachePurger> CachePurger,
|
|
AccountDeletionService Service);
|
|
}
|