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); fixture.Db.AiUsageRecords.AddRange( new AiUsageRecord { OwnerUserId = owner.Id, SourceType = "operation", SourceId = "owner-usage", TaskType = "synthetic", EstimatedTokenCount = 100, CreatedAtUtc = DateTimeOffset.UtcNow, }, new AiUsageRecord { OwnerUserId = other.Id, SourceType = "operation", SourceId = "other-usage", TaskType = "synthetic", EstimatedTokenCount = 100, CreatedAtUtc = DateTimeOffset.UtcNow, }); 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(await fixture.Db.AiUsageRecords.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id)); Assert.True(await fixture.Db.AiUsageRecords.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.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()), 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())) .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())) .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(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE wrong@example.test"), CancellationToken.None)); var stale = Assert.IsType(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(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 test) { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-deletion-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); try { var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root, ["AccountLifecycle:DeletionEnabled"] = enabled.ToString(), }).Build(); var environment = new Mock(); environment.SetupGet(item => item.ContentRootPath).Returns(root); var paths = new AppPaths(configuration, environment.Object); var currentUser = new Mock(); currentUser.SetupGet(item => item.UserId).Returns("owner"); var options = new DbContextOptionsBuilder() .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(); cachePurger.Setup(item => item.PurgeAsync(It.IsAny())).Returns(Task.CompletedTask); var service = new AccountDeletionService(db, inventory, tombstones, cachePurger.Object, configuration, cache, TimeProvider.System, NullLogger.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 CachePurger, AccountDeletionService Service); }