385 lines
17 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|