fix(documents): reject profile changes during submitted CV rendering

This commit is contained in:
cesnimda
2026-11-11 18:31:58 +01:00
parent dbae196291
commit 2cd561d71d
2 changed files with 64 additions and 0 deletions
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using System.Text.Json;
using Moq; using Moq;
using Xunit; using Xunit;
@@ -129,6 +130,65 @@ public sealed class SubmittedApplicationPackageTests
storage.Verify(x => x.SnapshotAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never); storage.Verify(x => x.SnapshotAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
} }
[Fact]
public async Task Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html()
{
var renderer = new Mock<ICvVariantService>();
var (db, service, _) = New("owner-1", renderer);
await using var dispose = db;
var job = await SeedAsync(db, "owner-1", null);
var career = new CareerProfileService(db);
var real = new CvVariantService(db, career, new ThemedCvRenderer());
renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.Returns(async (string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) =>
{
var changed = JsonSerializer.Deserialize<StructuredCvProfile>("{\"version\":\"1\",\"contact\":{\"fullName\":\"New during capture\",\"headline\":\"Updated canonical profile\"}}", new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
await career.SaveVersionAsync(owner, changed, "manual", 0, ct);
var rendered = await real.RenderAsync(owner, id, person, ct, expected);
Assert.Contains("New during capture", rendered!.Html);
return rendered;
});
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
Assert.Empty(db.SubmittedApplicationPackages);
Assert.Equal(1, (await db.CareerProfiles.SingleAsync()).Version);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Profile_created_or_removed_during_render_aborts_capture(bool created)
{
var renderer = new Mock<ICvVariantService>();
var (db, service, _) = New("owner-1", renderer);
await using var dispose = db;
var job = await SeedAsync(db, "owner-1", null);
if (created) { db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync()); await db.SaveChangesAsync(); }
var real = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
renderer.Setup(x => x.RenderAsync("owner-1", It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>(), It.IsAny<int?>()))
.Returns(async (string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) =>
{
if (created) db.CareerProfiles.Add(new CareerProfile { OwnerUserId = owner, Version = 1, ProfileJson = "{}" });
else db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync(ct));
await db.SaveChangesAsync(ct);
return await real.RenderAsync(owner, id, person, ct, expected);
});
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
Assert.Empty(db.SubmittedApplicationPackages);
}
[Fact]
public async Task Unchanged_absent_legacy_profile_remains_capturable()
{
var (db, service, _) = New("owner-1");
await using var dispose = db;
var job = await SeedAsync(db, "owner-1", null);
db.CareerProfiles.Remove(await db.CareerProfiles.SingleAsync()); await db.SaveChangesAsync();
Assert.NotNull(await service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
Assert.Null((await db.SubmittedApplicationPackages.SingleAsync()).CareerProfileJson);
}
private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> Storage) New(string userId, Mock<ICvVariantService>? renderOverride = null) private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> Storage) New(string userId, Mock<ICvVariantService>? renderOverride = null)
{ {
var currentUser = new Mock<ICurrentUserService>(); var currentUser = new Mock<ICurrentUserService>();
@@ -81,6 +81,10 @@ public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPa
.Select(v => new { v.Version, v.SettingsJson }).SingleOrDefaultAsync(ct); .Select(v => new { v.Version, v.SettingsJson }).SingleOrDefaultAsync(ct);
if (render is null || current is null || current.Version != variant.Version || current.SettingsJson != variant.SettingsJson) if (render is null || current is null || current.Version != variant.Version || current.SettingsJson != variant.SettingsJson)
throw new DbUpdateConcurrencyException("The selected CV changed before the submitted package could be captured."); throw new DbUpdateConcurrencyException("The selected CV changed before the submitted package could be captured.");
var currentProfile = await _db.CareerProfiles.AsNoTracking().Where(p => p.OwnerUserId == ownerUserId)
.Select(p => new { p.Version, p.ProfileJson }).FirstOrDefaultAsync(ct);
if (currentProfile?.Version != profile?.Version || currentProfile?.ProfileJson != profile?.ProfileJson)
throw new DbUpdateConcurrencyException("The Career Profile changed before the submitted package could be captured.");
} }
var version = (await _db.SubmittedApplicationPackages.AsNoTracking() var version = (await _db.SubmittedApplicationPackages.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId) .Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId)