From 2cd561d71d3bd33f5545e3a3746777efd458b57d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 11 Nov 2026 18:31:58 +0100 Subject: [PATCH] fix(documents): reject profile changes during submitted CV rendering --- .../SubmittedApplicationPackageTests.cs | 60 +++++++++++++++++++ .../SubmittedApplicationPackageService.cs | 4 ++ 2 files changed, 64 insertions(+) diff --git a/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs index 99fd5e53..b99bc76d 100644 --- a/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs @@ -4,6 +4,7 @@ using JobTrackerApi.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using System.Text.Json; using Moq; using Xunit; @@ -129,6 +130,65 @@ public sealed class SubmittedApplicationPackageTests storage.Verify(x => x.SnapshotAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Profile_changed_during_render_cannot_publish_old_profile_metadata_with_new_html() + { + var renderer = new Mock(); + 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(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (string owner, int id, CvRenderPerson person, CancellationToken ct, int? expected) => + { + var changed = JsonSerializer.Deserialize("{\"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(() => 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(); + 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(), It.IsAny(), It.IsAny(), It.IsAny())) + .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(() => 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 Storage) New(string userId, Mock? renderOverride = null) { var currentUser = new Mock(); diff --git a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs index 3c605d31..223a14e1 100644 --- a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs +++ b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs @@ -81,6 +81,10 @@ public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPa .Select(v => new { v.Version, v.SettingsJson }).SingleOrDefaultAsync(ct); 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."); + 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() .Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId)