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.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class AttachmentConsistencyTests { [Fact] public async Task Invalid_later_file_writes_no_rows_or_bytes() { await using var fixture = await Fixture.CreateAsync(); var job = await fixture.SeedJobAsync(); var files = Files(File("resume.pdf", "ok"), File("payload.exe", "bad")); var result = await fixture.Controller.Upload(files, job.Id, default); Assert.IsType(result); Assert.Empty(await fixture.Db.Attachments.ToListAsync()); Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories)); } [Fact] public async Task Cancelled_copy_cleans_staging_and_writes_no_rows() { await using var fixture = await Fixture.CreateAsync(); var job = await fixture.SeedJobAsync(); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => fixture.Controller.Upload(Files(File("resume.pdf", "content")), job.Id, cancellation.Token)); Assert.Empty(await fixture.Db.Attachments.ToListAsync()); Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories)); } [Fact] public async Task Committed_upload_with_failed_promotion_is_recovered_on_restart_pass() { await using var fixture = await Fixture.CreateAsync(); var job = await fixture.SeedJobAsync(); var failing = new FailingStorage(fixture.Storage) { FailPromote = true }; var controller = fixture.CreateController(failing); Assert.IsType(await controller.Upload(Files(File("resume.pdf", "content")), job.Id, default)); var row = await fixture.Db.Attachments.SingleAsync(); Assert.False(System.IO.File.Exists(row.FilePath)); Assert.True(System.IO.File.Exists(fixture.Storage.StagePath(row.FilePath))); var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default); Assert.Equal(1, recovered.Promoted); Assert.True(System.IO.File.Exists(row.FilePath)); Assert.False(System.IO.File.Exists(fixture.Storage.StagePath(row.FilePath))); var repeated = await fixture.Storage.ReconcileAsync(fixture.Db, default); Assert.Equal(new AttachmentReconciliationResult(0, 0, 0, 0, 0, 0, 0), repeated); } [Fact] public async Task Rename_changes_metadata_without_moving_bytes() { await using var fixture = await Fixture.CreateAsync(); var (job, row) = await fixture.SeedAttachmentAsync("original.pdf", "resume"); var originalPath = row.FilePath; Assert.IsType(await fixture.Controller.Rename(row.Id, new AttachmentsController.UpdateAttachmentRequest("renamed.pdf", "portfolio", null), default)); Assert.Equal("renamed.pdf", row.FileName); Assert.Equal(originalPath, row.FilePath); Assert.True(System.IO.File.Exists(originalPath)); Assert.True((await fixture.Db.JobApplications.FindAsync(job.Id))!.HasPortfolio); } [Fact] public async Task Failed_delete_purge_leaves_retryable_trash_and_reconciler_purges_it() { await using var fixture = await Fixture.CreateAsync(); var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume"); var deletePath = fixture.Storage.DeletePath(row.FilePath); var failing = new FailingStorage(fixture.Storage) { FailDeletePurge = true }; Assert.IsType(await fixture.CreateController(failing).Delete(row.Id, default)); Assert.Empty(await fixture.Db.Attachments.ToListAsync()); Assert.True(System.IO.File.Exists(deletePath)); var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default); Assert.Equal(1, recovered.Purged); Assert.False(System.IO.File.Exists(deletePath)); } [Fact] public async Task Restart_restores_quarantined_file_when_database_row_still_exists() { await using var fixture = await Fixture.CreateAsync(); var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume"); var deletePath = fixture.Storage.DeletePath(row.FilePath); fixture.Storage.Quarantine(row.FilePath, deletePath); var recovered = await fixture.Storage.ReconcileAsync(fixture.Db, default); Assert.Equal(1, recovered.Restored); Assert.True(System.IO.File.Exists(row.FilePath)); Assert.False(System.IO.File.Exists(deletePath)); } [Fact] public async Task Unknown_legacy_orphan_is_reported_and_preserved() { await using var fixture = await Fixture.CreateAsync(); var folder = Path.Combine(fixture.Paths.AttachmentsRoot, "legacy"); Directory.CreateDirectory(folder); var orphan = Path.Combine(folder, "unknown.pdf"); await System.IO.File.WriteAllTextAsync(orphan, "unknown"); var result = await fixture.Storage.ReconcileAsync(fixture.Db, default); Assert.Equal(1, result.UnknownOrphans); Assert.True(System.IO.File.Exists(orphan)); } [Fact] public async Task Database_failure_during_upload_removes_all_staged_bytes() { var failure = new SaveFailureInterceptor(); await using var fixture = await Fixture.CreateAsync(failure); var job = await fixture.SeedJobAsync(); failure.FailOnSavingCall = 1; await Assert.ThrowsAsync(() => fixture.Controller.Upload(Files(File("resume.pdf", "content")), job.Id, default)); fixture.Db.ChangeTracker.Clear(); Assert.Empty(await fixture.Db.Attachments.AsNoTracking().ToListAsync()); Assert.Empty(Directory.EnumerateFiles(fixture.Paths.AttachmentsRoot, "*", SearchOption.AllDirectories)); } [Fact] public async Task Database_failure_during_delete_restores_quarantined_bytes_and_row() { var failure = new SaveFailureInterceptor(); await using var fixture = await Fixture.CreateAsync(failure); var (_, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume"); var path = row.FilePath; failure.FailOnSavingCall = 1; await Assert.ThrowsAsync(() => fixture.Controller.Delete(row.Id, default)); fixture.Db.ChangeTracker.Clear(); Assert.Single(await fixture.Db.Attachments.AsNoTracking().ToListAsync()); Assert.True(System.IO.File.Exists(path)); Assert.False(System.IO.File.Exists(fixture.Storage.DeletePath(path))); } [Fact] public async Task Purpose_and_derived_flags_roll_back_together() { var failure = new SaveFailureInterceptor(); await using var fixture = await Fixture.CreateAsync(failure); var (job, row) = await fixture.SeedAttachmentAsync("resume.pdf", "resume"); failure.FailOnSavingCall = 2; await Assert.ThrowsAsync(() => fixture.Controller.Rename( row.Id, new AttachmentsController.UpdateAttachmentRequest(null, "portfolio", null), default)); fixture.Db.ChangeTracker.Clear(); Assert.Equal("resume", (await fixture.Db.Attachments.AsNoTracking().SingleAsync()).Purpose); var storedJob = await fixture.Db.JobApplications.AsNoTracking().SingleAsync(x => x.Id == job.Id); Assert.True(storedJob.HasResume); Assert.False(storedJob.HasPortfolio); } [Fact] public async Task Storage_rejects_paths_outside_root() { await using var fixture = await Fixture.CreateAsync(); Assert.False(fixture.Storage.IsManagedPath(Path.Combine(Path.GetTempPath(), $"outside-{Guid.NewGuid():N}.pdf"))); } [Fact] public async Task Repeated_same_name_uploads_use_distinct_storage_paths() { await using var fixture = await Fixture.CreateAsync(); var job = await fixture.SeedJobAsync(); Assert.IsType(await fixture.Controller.Upload(Files(File("resume.pdf", "first")), job.Id, default)); Assert.IsType(await fixture.Controller.Upload(Files(File("resume.pdf", "second")), job.Id, default)); var rows = await fixture.Db.Attachments.AsNoTracking().ToListAsync(); Assert.Equal(2, rows.Count); Assert.Equal(2, rows.Select(x => x.FilePath).Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(rows, row => Assert.True(System.IO.File.Exists(row.FilePath))); } [Fact] public async Task Exact_size_limit_is_accepted_and_one_byte_over_is_rejected() { await using var fixture = await Fixture.CreateAsync(); var job = await fixture.SeedJobAsync(); const int limit = 10 * 1024 * 1024; Assert.IsType(await fixture.Controller.Upload(Files(FileOfLength("limit.pdf", limit)), job.Id, default)); Assert.IsType(await fixture.Controller.Upload(Files(FileOfLength("too-large.pdf", limit + 1)), job.Id, default)); var row = await fixture.Db.Attachments.AsNoTracking().SingleAsync(); Assert.Equal(limit, row.FileSize); Assert.Equal(limit, new FileInfo(row.FilePath).Length); } [Fact] public async Task Another_users_attachment_cannot_be_downloaded_renamed_or_deleted() { await using var fixture = await Fixture.CreateAsync(); var company = new Company { OwnerUserId = "user-2", Name = "Other" }; fixture.Db.Companies.Add(company); await fixture.Db.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "user-2", CompanyId = company.Id, JobTitle = "Other job", Status = "Applied" }; fixture.Db.JobApplications.Add(job); await fixture.Db.SaveChangesAsync(); var path = fixture.Storage.CreateFinalPath(job.Id, $"other-{Guid.NewGuid():N}.pdf"); await System.IO.File.WriteAllTextAsync(path, "other user"); var row = new Attachment { JobApplicationId = job.Id, FileName = "other.pdf", FilePath = path, FileType = "application/pdf", FileSize = 10 }; fixture.Db.Attachments.Add(row); await fixture.Db.SaveChangesAsync(); fixture.Db.ChangeTracker.Clear(); Assert.IsType(await fixture.Controller.Download(row.Id, default)); Assert.IsType(await fixture.Controller.Rename(row.Id, new AttachmentsController.UpdateAttachmentRequest("stolen.pdf", null, null), default)); Assert.IsType(await fixture.Controller.Delete(row.Id, default)); Assert.True(System.IO.File.Exists(path)); Assert.Single(await fixture.Db.Attachments.IgnoreQueryFilters().Where(x => x.Id == row.Id).ToListAsync()); } private static FormFile File(string name, string content) { var bytes = System.Text.Encoding.UTF8.GetBytes(content); return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "files", name) { Headers = new HeaderDictionary(), ContentType = "application/pdf", }; } private static FormFile FileOfLength(string name, int length) { var stream = new MemoryStream(new byte[length]); return new FormFile(stream, 0, length, "files", name) { Headers = new HeaderDictionary(), ContentType = "application/pdf", }; } private static FormFileCollection Files(params FormFile[] files) { var result = new FormFileCollection(); foreach (var file in files) result.Add(file); return result; } private sealed class FailingStorage(IAttachmentStorage inner) : IAttachmentStorage { public bool FailPromote { get; init; } public bool FailDeletePurge { get; init; } public string CreateFinalPath(int jobId, string storedFileName) => inner.CreateFinalPath(jobId, storedFileName); public string StagePath(string finalPath) => inner.StagePath(finalPath); public string DeletePath(string finalPath) => inner.DeletePath(finalPath); public bool IsManagedPath(string path) => inner.IsManagedPath(path); public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken); public void Promote(string stagePath, string finalPath) { if (FailPromote) throw new IOException("Synthetic promotion failure."); inner.Promote(stagePath, finalPath); } public void Quarantine(string finalPath, string deletePath) => inner.Quarantine(finalPath, deletePath); public void Restore(string deletePath, string finalPath) => inner.Restore(deletePath, finalPath); public void Purge(string path) { if (FailDeletePurge && path.EndsWith(".deleting", StringComparison.Ordinal)) throw new IOException("Synthetic purge failure."); inner.Purge(path); } public Task ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken) => inner.ReconcileAsync(db, cancellationToken); } private sealed class SaveFailureInterceptor : SaveChangesInterceptor { public int FailOnSavingCall { get; set; } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (FailOnSavingCall > 0 && --FailOnSavingCall == 0) throw new InvalidOperationException("Synthetic database failure."); return base.SavingChangesAsync(eventData, result, cancellationToken); } } private sealed class Fixture : IAsyncDisposable { private readonly SqliteConnection _connection; private readonly string _root; public JobTrackerContext Db { get; } public AppPaths Paths { get; } public AttachmentStorage Storage { get; } public AttachmentsController Controller { get; } private Fixture(SqliteConnection connection, string root, JobTrackerContext db, AppPaths paths, AttachmentStorage storage) { _connection = connection; _root = root; Db = db; Paths = paths; Storage = storage; Controller = CreateController(storage); } public static async Task CreateAsync(SaveChangesInterceptor? interceptor = null) { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-consistency-{Guid.NewGuid():N}"); var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(); var environment = new Mock(); environment.SetupGet(x => x.ContentRootPath).Returns(root); var paths = new AppPaths(config, environment.Object); var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var currentUser = new Mock(); currentUser.SetupGet(x => x.UserId).Returns("user-1"); var options = new DbContextOptionsBuilder().UseSqlite(connection); if (interceptor is not null) options.AddInterceptors(interceptor); var db = new JobTrackerContext(options.Options, currentUser.Object); await db.Database.EnsureCreatedAsync(); return new Fixture(connection, root, db, paths, new AttachmentStorage(paths)); } public AttachmentsController CreateController(IAttachmentStorage storage) => new(Paths, Db, storage: storage) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; public async Task SeedJobAsync() { var company = new Company { OwnerUserId = "user-1", Name = "Acme" }; Db.Companies.Add(company); await Db.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "user-1", CompanyId = company.Id, JobTitle = "Developer", Status = "Applied" }; Db.JobApplications.Add(job); await Db.SaveChangesAsync(); return job; } public async Task<(JobApplication Job, Attachment Row)> SeedAttachmentAsync(string name, string purpose) { var job = await SeedJobAsync(); var finalPath = Storage.CreateFinalPath(job.Id, $"seed-{Guid.NewGuid():N}.pdf"); await System.IO.File.WriteAllTextAsync(finalPath, "synthetic"); var row = new Attachment { JobApplicationId = job.Id, FileName = name, FilePath = finalPath, FileType = "application/pdf", FileSize = 9, Purpose = purpose }; Db.Attachments.Add(row); job.HasResume = purpose == "resume"; await Db.SaveChangesAsync(); return (job, row); } public async ValueTask DisposeAsync() { await Db.DisposeAsync(); await _connection.DisposeAsync(); if (Directory.Exists(_root)) Directory.Delete(_root, true); } } }