Files
jobtrackingapp/JobTrackerApi.Tests/PdfRenderAdmissionTests.cs
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

305 lines
15 KiB
C#

using System.Diagnostics;
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using JobTrackerApi.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
[CollectionDefinition("PDF process environment", DisableParallelization = true)]
public sealed class PdfProcessCollection;
[Collection("PDF process environment")]
public sealed class PdfRenderAdmissionTests
{
[Fact]
public async Task Default_limit_refuses_second_actual_browser_until_first_exits()
{
using var fixture = new Fixture();
var first = fixture.Exporter.ExportAsync("owner-a", Render("hold"), default);
try
{
await fixture.WaitForStarts(1);
var excess = fixture.Exporter.ExportAsync("owner-b", Render("hold"), default);
// Assert the real process boundary, not just a mocked semaphore.
await Assert.ThrowsAsync<PdfCapacityException>(() => excess);
Assert.True(excess.IsFaulted, $"An excess export must be rejected; actual browser launches: {fixture.Started().Length}.");
Assert.Single(fixture.Started());
}
finally
{
fixture.Release();
await first;
}
}
[Fact]
public async Task Two_exporter_instances_share_global_and_owner_capacity_without_waiting()
{
using var fixture = new Fixture(limit: 2);
var other = fixture.CreateExporter();
var a = fixture.Exporter.ExportAsync("a", Render("hold"), default);
await fixture.WaitForStarts(1);
await Assert.ThrowsAsync<PdfCapacityException>(() => other.ExportAsync("a", Render("hold"), default));
var b = other.ExportAsync("b", Render("hold"), default);
try
{
await fixture.WaitForStarts(2);
for (var i = 0; i < 10; i++)
await Assert.ThrowsAsync<PdfCapacityException>(() => other.ExportAsync($"c-{i}", Render("hold"), default));
Assert.Equal(2, fixture.Started().Length);
}
finally { fixture.Release(); await Task.WhenAll(a, b); }
fixture.AssertProcessesExited();
Assert.StartsWith("%PDF-", System.Text.Encoding.UTF8.GetString((await other.ExportAsync("a", Render("success"), default)).Bytes));
}
[Fact]
public async Task Cancelled_admission_has_no_queue_or_late_launch()
{
using var fixture = new Fixture();
var a = fixture.Exporter.ExportAsync("a", Render("hold"), default);
await fixture.WaitForStarts(1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Exporter.ExportAsync("b", Render("success"), cancelled.Token));
fixture.Release();
await a;
Assert.Single(fixture.Started());
await fixture.Exporter.ExportAsync("b", Render("success"), default);
}
[Theory]
[InlineData("fail")]
[InlineData("no-output")]
[InlineData("cancel")]
[InlineData("timeout")]
public async Task Repeated_failures_restore_all_slots_and_remove_partial_outputs(string failure)
{
using var fixture = new Fixture(limit: 2, timeout: 1);
for (var cycle = 0; cycle < 3; cycle++)
{
using var cancel = new CancellationTokenSource();
var mode = failure is "cancel" or "timeout" ? "hold" : failure;
var tasks = new[] { fixture.Exporter.ExportAsync("a", Render(mode), cancel.Token), fixture.Exporter.ExportAsync("b", Render(mode), cancel.Token) };
await fixture.WaitForStarts((cycle + 1) * 2);
if (failure == "cancel") cancel.Cancel();
foreach (var task in tasks)
{
if (failure == "cancel") await Assert.ThrowsAnyAsync<OperationCanceledException>(() => task);
else if (failure == "timeout") await Assert.ThrowsAsync<PdfRenderTimeoutException>(() => task);
else await Assert.ThrowsAsync<InvalidOperationException>(() => task);
}
fixture.AssertProcessesExited();
Assert.Empty(Directory.GetFiles(fixture.Paths.CvExportsRoot, "*.pdf", SearchOption.AllDirectories));
foreach (var started in fixture.Started()) Assert.False(Directory.Exists(Path.GetDirectoryName(File.ReadAllText(started))));
}
var next = await Task.WhenAll(fixture.Exporter.ExportAsync("a", Render("success"), default), fixture.Exporter.ExportAsync("b", Render("success"), default));
Assert.All(next, artifact => Assert.True(artifact.Bytes.Length > 0));
}
[Fact]
public async Task Launch_exception_does_not_consume_capacity()
{
using var fixture = new Fixture();
var browser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
var invalid = Path.Combine(fixture.Root, "not-an-executable");
File.WriteAllText(invalid, "inert");
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", invalid);
await Assert.ThrowsAsync<System.ComponentModel.Win32Exception>(() => fixture.Exporter.ExportAsync("a", Render("success"), default));
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", browser);
await fixture.Exporter.ExportAsync("a", Render("success"), default);
}
[Fact]
public async Task Browser_output_pipes_are_drained_without_deadlock()
{
using var fixture = new Fixture(timeout: 5);
var result = await fixture.Exporter.ExportAsync("a", Render("logs"), default);
Assert.True(result.Bytes.Length > 0);
fixture.AssertProcessesExited();
}
[Fact]
public async Task Shutdown_cancels_active_process_and_rejects_new_work()
{
using var fixture = new Fixture();
var active = fixture.Exporter.ExportAsync("a", Render("hold"), default);
await fixture.WaitForStarts(1);
fixture.Stopping.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => active);
fixture.AssertProcessesExited();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => fixture.Exporter.ExportAsync("b", Render("success"), default));
Assert.Single(fixture.Started());
}
[Theory]
[InlineData("MaxConcurrentRenders", "0")]
[InlineData("MaxConcurrentRenders", "-1")]
[InlineData("MaxConcurrentRenders", "9")]
[InlineData("MaxConcurrentRenders", "not-a-number")]
[InlineData("RenderTimeoutSeconds", "0")]
[InlineData("RenderTimeoutSeconds", "-1")]
[InlineData("RenderTimeoutSeconds", "61")]
public async Task Invalid_configuration_prevents_host_start(string name, string value)
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration[$"CvExports:{name}"] = value;
builder.Services.AddOptions<PdfRenderOptions>().BindConfiguration("CvExports").ValidateDataAnnotations().ValidateOnStart();
using var host = builder.Build();
var error = await Record.ExceptionAsync(() => host.StartAsync());
Assert.True(error is OptionsValidationException or InvalidOperationException, error?.ToString() ?? "Host unexpectedly started");
}
[Fact]
public void Unconfirmed_process_termination_disables_new_admission_even_after_lease_disposal()
{
var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), NullLogger<PdfRenderAdmission>.Instance);
using (admission.Enter("a", default)) admission.Disable();
Assert.Throws<PdfCapacityException>(() => admission.Enter("b", default));
}
[Fact]
public void Failing_observability_cannot_strand_an_admission_slot()
{
var logger = new Mock<ILogger<PdfRenderAdmission>>();
var broken = true;
logger.Setup(x => x.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception?>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
.Callback(() => { if (broken) throw new InvalidOperationException("Fixture logging failure"); });
var admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions()), logger.Object);
for (var i = 0; i < 3; i++) Assert.Throws<InvalidOperationException>(() => admission.Enter("a", default));
broken = false;
using var lease = admission.Enter("a", default);
}
[Theory]
[InlineData(true, 503, "pdf_capacity_exceeded")]
[InlineData(false, 504, "pdf_render_timeout")]
public async Task Api_errors_are_safe_deterministic_and_distinct(bool busy, int status, string code)
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
Assert.True(await new PdfRenderExceptionHandler().TryHandleAsync(context, busy ? new PdfCapacityException() : new PdfRenderTimeoutException(), default));
Assert.Equal(status, context.Response.StatusCode);
Assert.Equal("no-store", context.Response.Headers.CacheControl);
Assert.Equal(busy ? "3" : "", context.Response.Headers.RetryAfter.ToString());
context.Response.Body.Position = 0;
Assert.Contains(code, await new StreamReader(context.Response.Body).ReadToEndAsync());
}
[Fact]
public async Task Profile_controller_uses_same_real_exporter_boundary_after_local_AI_preparation()
{
using var fixture = new Fixture();
var active = fixture.Exporter.ExportAsync("other-owner", Render("hold"), default);
await fixture.WaitForStarts(1);
var user = new ApplicationUser { Id = "profile-owner", ProfileCvText = "## Summary\nSynthetic engineer" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var ai = new Mock<ISummarizerService>();
ai.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("## Summary\nSynthetic engineer");
await using var db = TestHostFactory.CreateInMemoryDb(user.Id);
var controller = new ProfileCvController(users.Object, ai.Object, db, fixture.Paths,
cvAiClassifier: NoOpCvAiClassifier.Instance, cvAiNormalizer: NoOpCvAiNormalizer.Instance,
cvTemplateRenderer: new CvTemplateRenderer(), cvPdfExporter: fixture.Exporter)
{ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
try
{
await Assert.ThrowsAsync<PdfCapacityException>(() => controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default));
Assert.Single(fixture.Started());
}
finally { fixture.Release(); await active; }
Assert.IsType<FileContentResult>(await controller.ExportProfileCvPdf(new ProfileCvController.RewriteSectionRequest { SourceText = user.ProfileCvText }, default));
}
private static TailoredCvRenderResult Render(string mode) => new("synthetic", "synthetic.pdf", mode);
private sealed class Fixture : IDisposable
{
public string Root { get; } = Path.Combine(Path.GetTempPath(), $"jobtracker-b03-{Guid.NewGuid():N}");
public PlaywrightCvPdfExporter Exporter { get; }
public AppPaths Paths { get; }
public CancellationTokenSource Stopping { get; } = new();
private readonly IConfiguration _config;
private readonly PdfRenderAdmission _admission;
private readonly string? _oldBrowser = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
private readonly string? _oldRoot = Environment.GetEnvironmentVariable("PDF_STUB_ROOT");
public Fixture(int limit = 1, int timeout = 60)
{
Directory.CreateDirectory(Root);
Environment.SetEnvironmentVariable("PDF_STUB_ROOT", Root);
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", Path.Combine(AppContext.BaseDirectory,
"PdfBrowserStub", OperatingSystem.IsWindows() ? "PdfBrowserStub.exe" : "PdfBrowserStub"));
_config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = Root }).Build();
var environment = new Mock<IHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(Root);
Paths = new AppPaths(_config, environment.Object);
_admission = new PdfRenderAdmission(Options.Create(new PdfRenderOptions { MaxConcurrentRenders = limit, RenderTimeoutSeconds = timeout }), NullLogger<PdfRenderAdmission>.Instance);
Exporter = CreateExporter();
}
public PlaywrightCvPdfExporter CreateExporter()
{
var lifetime = new Mock<IHostApplicationLifetime>();
lifetime.SetupGet(x => x.ApplicationStopping).Returns(Stopping.Token);
return new PlaywrightCvPdfExporter(Paths, NullLogger<PlaywrightCvPdfExporter>.Instance, _config, _admission, lifetime.Object);
}
public void AssertProcessesExited()
{
foreach (var file in Started())
{
try
{
using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file)));
Assert.True(process.HasExited, "Capacity must not be returned before the child exits.");
}
catch (ArgumentException) { }
}
}
public string[] Started() => Directory.GetFiles(Root, "*.started");
public async Task WaitForStarts(int count)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
while (Started().Length < count) await Task.Delay(20, timeout.Token);
}
public void Release() => File.WriteAllText(Path.Combine(Root, "release"), "release");
public void Dispose()
{
Release();
// Only stop fixture-owned process IDs, after checking their executable identity.
foreach (var file in Started())
{
try
{
using var process = Process.GetProcessById(int.Parse(Path.GetFileNameWithoutExtension(file)));
if (!process.HasExited && process.ProcessName == "PdfBrowserStub")
{
process.Kill(entireProcessTree: true);
process.WaitForExit(5000);
}
}
catch (ArgumentException) { }
}
Environment.SetEnvironmentVariable("CV_PDF_BROWSER_PATH", _oldBrowser);
Environment.SetEnvironmentVariable("PDF_STUB_ROOT", _oldRoot);
Stopping.Dispose();
Directory.Delete(Root, recursive: true);
}
}
}