feat: complete release readiness work

- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
This commit is contained in:
cesnimda
2026-07-31 16:54:16 +02:00
parent a23c3dfc97
commit ce76046a29
1634 changed files with 6889 additions and 135429 deletions
+11
View File
@@ -5,6 +5,17 @@ namespace JobTrackerApi.Tests;
public sealed class AccountPlansTests
{
[Theory]
[InlineData("active", true)]
[InlineData("trialing", true)]
[InlineData("past_due", false)]
[InlineData("canceled", false)]
[InlineData(null, false)]
public void Premium_subscription_status_requires_current_access(string? status, bool expected)
{
Assert.Equal(expected, AccountPlans.IsPremiumSubscriptionStatus(status));
}
[Fact]
public void Premium_role_receives_higher_cost_capabilities()
{
@@ -228,6 +228,25 @@ public sealed class ApplicationChecklistTests
Assert.Equal(custom!.Id, next!.Id);
}
[Fact]
public async Task Learning_recommendations_preserve_user_decisions_and_reopen_only_auto_completed_items()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
var first = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
var kubernetes = first.Single(item => item.Keyword == "Kubernetes");
await svc.UpdateAsync("user-1", job.Id, kubernetes.Id,
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
await svc.SyncLearningRecommendationsAsync("user-1", job.Id, [], default);
var reopened = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
Assert.Equal(ChecklistStatuses.Done, reopened.Single(item => item.Keyword == "Kubernetes").Status);
Assert.Equal(ChecklistStatuses.Pending, reopened.Single(item => item.Keyword == "AWS").Status);
}
[Fact]
public async Task Interview_prep_is_only_outstanding_at_the_interview_stage()
{
@@ -252,6 +271,7 @@ public sealed class ApplicationChecklistTests
Assert.Null(await svc.GetAsync("user-1", other.Id, default));
Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default));
Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default));
Assert.Empty(await svc.SyncLearningRecommendationsAsync("user-1", other.Id, ["Kubernetes"], default));
}
[Fact]
@@ -0,0 +1,49 @@
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class BillingControllerTests
{
[Fact]
public async Task Webhook_rejects_an_invalid_Stripe_signature()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Stripe:SecretKey"] = "sk_test_fake",
["Stripe:PricePremium"] = "price_fake",
["Stripe:WebhookSecret"] = "whsec_fake",
["App:PublicBaseUrl"] = "https://example.test",
}).Build();
var userStore = new Mock<IUserStore<ApplicationUser>>();
var users = new Mock<UserManager<ApplicationUser>>(
userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(), Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
var roleStore = new Mock<IRoleStore<IdentityRole>>();
var roles = new Mock<RoleManager<IdentityRole>>(
roleStore.Object, Array.Empty<IRoleValidator<IdentityRole>>(), new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(), NullLogger<RoleManager<IdentityRole>>.Instance);
var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
};
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
controller.Request.Headers["Stripe-Signature"] = "invalid";
var result = await controller.Webhook(CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
}
}
@@ -0,0 +1,44 @@
using System.Reflection;
using JobTrackerApi.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvExportRetentionTests
{
[Fact]
public void Export_pruning_removes_only_expired_date_directories()
{
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-export-retention-{Guid.NewGuid():N}");
var exportsRoot = Path.Combine(root, "CvExports");
var old = Path.Combine(exportsRoot, "20260101");
var keep = Path.Combine(exportsRoot, "20260731");
var unrelated = Path.Combine(exportsRoot, "manual");
Directory.CreateDirectory(old);
Directory.CreateDirectory(keep);
Directory.CreateDirectory(unrelated);
try
{
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 exporter = new PlaywrightCvPdfExporter(new AppPaths(config, environment.Object), NullLogger<PlaywrightCvPdfExporter>.Instance, config);
var prune = typeof(PlaywrightCvPdfExporter).GetMethod("PruneExpiredExports", BindingFlags.Instance | BindingFlags.NonPublic)!;
prune.Invoke(exporter, [new DateOnly(2026, 7, 1)]);
Assert.False(Directory.Exists(old));
Assert.True(Directory.Exists(keep));
Assert.True(Directory.Exists(unrelated));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
}
@@ -209,6 +209,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
var result = await controller.Create(request, CancellationToken.None);
Assert.NotNull(result);
var created = Assert.IsType<CreatedAtActionResult>(result.Result);
Assert.IsType<JobApplicationDto>(created.Value);
var saved = await db.JobApplications.FirstAsync();
Assert.Equal(60000m, saved.SalaryMin);
Assert.Equal(70000m, saved.SalaryMax);
@@ -20,6 +20,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
</ItemGroup>
</Project>
@@ -226,6 +226,74 @@ public sealed class ProfileCvControllerTests
Assert.Null(user.ProfileCvText);
}
[Fact]
public async Task Background_processing_bypasses_the_http_user_filter_for_the_owned_run()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "# Ada Lovelace\n\n## Skills\nC#" };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(
It.Is<string>(instruction => instruction.StartsWith("Rewrite this CV", StringComparison.Ordinal)),
It.IsAny<string>(), 1800, 500))
.ReturnsAsync(user.ProfileCvText);
aiService.Setup(x => x.SummarizeSectionAsync(
It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)),
It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{"version":"1","contact":{"fullName":"Ada Lovelace"},"summary":[],"jobs":[],"education":[],"skills":["C#"],"languages":[],"interests":[],"otherSections":[]}
""");
await using var db = CreateDb(userId: null);
var paths = CreatePaths();
var orphanedPath = Path.Combine(paths.CvArtifactsRoot, "orphaned.pdf");
await System.IO.File.WriteAllTextAsync(orphanedPath, "obsolete");
var currentPath = Path.Combine(paths.CvArtifactsRoot, "current.pdf");
await System.IO.File.WriteAllTextAsync(currentPath, "current");
db.CvUploadArtifacts.Add(new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = "orphaned.pdf",
StoredFileName = "orphaned.pdf",
MimeType = "application/pdf",
ByteSize = 8,
Sha256 = "orphaned",
StoragePath = orphanedPath,
});
var currentArtifact = new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = "current.pdf",
StoredFileName = "current.pdf",
MimeType = "application/pdf",
ByteSize = 7,
Sha256 = "current",
StoragePath = currentPath,
};
db.CvUploadArtifacts.Add(currentArtifact);
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
Trigger = "improve",
ParserVersion = "test",
NormalizerVersion = "test",
LlmPromptVersion = "test",
Status = "queued",
};
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
user.CurrentCvUploadArtifactId = currentArtifact.Id;
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None);
Assert.Equal("pending_review", run.Status);
Assert.NotNull(run.CompletedAtUtc);
Assert.Equal(currentArtifact.Id, Assert.Single(await db.CvUploadArtifacts.IgnoreQueryFilters().ToListAsync()).Id);
Assert.False(System.IO.File.Exists(orphanedPath));
Assert.True(System.IO.File.Exists(currentPath));
}
[Fact]
public async Task Upload_reconstructs_flattened_pdf_cv_before_save()
{
@@ -1255,7 +1323,7 @@ public sealed class ProfileCvControllerTests
};
}
private static JobTrackerContext CreateDb(string userId = "user-1")
private static JobTrackerContext CreateDb(string? userId = "user-1")
{
return TestHostFactory.CreateInMemoryDb(userId);
}
@@ -0,0 +1,61 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Moq;
using System.Reflection;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class PublicCvControllerTests
{
[Fact]
public void Pdf_download_is_rate_limited()
{
var attribute = typeof(PublicCvController).GetMethod(nameof(PublicCvController.DownloadPdf))!
.GetCustomAttribute<EnableRateLimitingAttribute>();
Assert.Equal("public-pdf", attribute?.PolicyName);
}
[Fact]
public async Task Pdf_download_uses_the_public_variant_render()
{
var user = new ApplicationUser { Id = "owner-1", FirstName = "Ada", LastName = "Lovelace" };
var variants = new Mock<ICvVariantService>();
var pdf = new Mock<ICvPdfExporter>();
var render = new ThemedCvRenderResult("modern", "ada-cv.pdf", "<html>CV</html>");
variants.Setup(x => x.GetPublicOwnerAsync("public-slug", It.IsAny<CancellationToken>())).ReturnsAsync(user.Id);
variants.Setup(x => x.RenderPublicAsync("public-slug", It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((render, user.Id));
pdf.Setup(x => x.ExportAsync(It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvPdfArtifact("ada-cv.pdf", "unused", [1, 2, 3]));
var controller = new PublicCvController(TestHostFactory.CreateUserManager(user).Object, variants.Object, pdf.Object);
var result = Assert.IsType<FileContentResult>(await controller.DownloadPdf("public-slug", CancellationToken.None));
Assert.Equal("application/pdf", result.ContentType);
Assert.Equal("ada-cv.pdf", result.FileDownloadName);
Assert.Equal([1, 2, 3], result.FileContents);
pdf.Verify(x => x.ExportAsync(
It.Is<TailoredCvRenderResult>(value => value.TemplateId == "modern" && value.Html == "<html>CV</html>"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Pdf_download_does_not_render_an_unknown_or_private_slug()
{
var variants = new Mock<ICvVariantService>();
variants.Setup(x => x.GetPublicOwnerAsync("private-slug", It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
var pdf = new Mock<ICvPdfExporter>();
var controller = new PublicCvController(TestHostFactory.CreateUserManager().Object, variants.Object, pdf.Object);
Assert.IsType<NotFoundResult>(await controller.DownloadPdf("private-slug", CancellationToken.None));
variants.Verify(x => x.RenderPublicAsync(It.IsAny<string>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()), Times.Never);
pdf.VerifyNoOtherCalls();
}
}