Files
jobtrackingapp/JobTrackerApi.Tests/AttachmentConsistencyTests.cs
T

385 lines
17 KiB
C#

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<BadRequestObjectResult>(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<OperationCanceledException>(() => 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<AcceptedResult>(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<NoContentResult>(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<AcceptedResult>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<OkResult>(await fixture.Controller.Upload(Files(File("resume.pdf", "first")), job.Id, default));
Assert.IsType<OkResult>(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<OkResult>(await fixture.Controller.Upload(Files(FileOfLength("limit.pdf", limit)), job.Id, default));
Assert.IsType<BadRequestObjectResult>(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<NotFoundResult>(await fixture.Controller.Download(row.Id, default));
Assert.IsType<NotFoundResult>(await fixture.Controller.Rename(row.Id, new AttachmentsController.UpdateAttachmentRequest("stolen.pdf", null, null), default));
Assert.IsType<NotFoundResult>(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<AttachmentReconciliationResult> ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken) => inner.ReconcileAsync(db, cancellationToken);
}
private sealed class SaveFailureInterceptor : SaveChangesInterceptor
{
public int FailOnSavingCall { get; set; }
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> 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<Fixture> CreateAsync(SaveChangesInterceptor? interceptor = null)
{
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-consistency-{Guid.NewGuid():N}");
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build();
var environment = new Mock<IHostEnvironment>();
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<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns("user-1");
var options = new DbContextOptionsBuilder<JobTrackerContext>().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<JobApplication> 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);
}
}
}