fix(cv): harden document parsing

Upgrade and hash-lock upload-facing parser dependencies, reject resource-heavy or mismatched inputs, remove unsafe backend binary fallbacks, and prevent internal parser failures from leaking to users.
This commit is contained in:
cesnimda
2026-08-30 11:00:52 +02:00
parent a74daa7aa4
commit a8bf505ce5
14 changed files with 1869 additions and 110 deletions
@@ -1112,6 +1112,70 @@ public sealed class ProfileCvControllerTests
Assert.Equal("pending_review", run.Status);
}
[Fact]
public async Task Binary_upload_does_not_fall_back_to_in_process_parsing()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
ConfigureWorkerUser(userManager, user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((AiTextExtractionResult?)null);
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var bytes = Encoding.UTF8.GetBytes("not a safe DOCX package");
var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "resume.docx")
{
Headers = new HeaderDictionary(),
ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
};
Assert.IsType<AcceptedResult>(await controller.Upload(file));
var run = await db.CvExtractionRuns.SingleAsync();
var outcome = Assert.IsType<CvProcessingOutcome>(await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None));
Assert.False(outcome.Succeeded);
Assert.Equal("The document extraction service could not read this CV safely.", outcome.FailureMessage);
Assert.Equal("failed", run.Status);
Assert.Equal(outcome.FailureMessage, run.ErrorMessage);
}
[Fact]
public async Task Unexpected_extraction_errors_are_not_exposed_to_users()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
ConfigureWorkerUser(userManager, user);
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
var aiService = new Mock<ISummarizerService>();
aiService
.Setup(x => x.ExtractTextAsync(It.IsAny<Stream>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new IOException(@"Secret parser path C:\private\cv.docx"));
await using var db = CreateDb();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
var source = Encoding.UTF8.GetBytes("# Resume");
var file = new FormFile(new MemoryStream(source), 0, source.Length, "file", "resume.md")
{
Headers = new HeaderDictionary(),
ContentType = "text/markdown"
};
Assert.IsType<AcceptedResult>(await controller.Upload(file));
var run = await db.CvExtractionRuns.SingleAsync();
var outcome = Assert.IsType<CvProcessingOutcome>(await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None));
Assert.False(outcome.Succeeded);
Assert.Equal("CV processing failed unexpectedly. Please try again.", outcome.FailureMessage);
Assert.Equal(outcome.FailureMessage, run.ErrorMessage);
Assert.DoesNotContain("private", outcome.FailureMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
{