Files
jobtrackingapp/JobTrackerApi.Tests/AccountDeletionTests.cs
T
cesnimda 134aac7bcf
CI and Deploy / test (pull_request) Successful in 5m19s
CI and Deploy / deploy (pull_request) Has been skipped
feat(ai): centralize durable usage
Add a content-free usage ledger with legacy backfill. Reserve Workspace and durable Strategy/CV work before execution so deleted history or duplicate admission cannot reset limits.
2026-08-15 20:03:06 +02:00

334 lines
19 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);
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<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);
}