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:
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -241,7 +242,7 @@ public sealed class CvExtractionCoverageTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Docx_fallback_preserves_paragraph_and_table_boundaries()
|
||||
public async Task Binary_document_fallback_is_disabled()
|
||||
{
|
||||
await using var package = new MemoryStream();
|
||||
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
|
||||
@@ -263,14 +264,23 @@ public sealed class CvExtractionCoverageTests
|
||||
}
|
||||
package.Position = 0;
|
||||
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
|
||||
var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx" }));
|
||||
var method = typeof(ProfileCvController).GetMethod("ExtractPlainTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx", CancellationToken.None }));
|
||||
var extracted = await task;
|
||||
|
||||
Assert.Contains("Technical Skills\n", extracted);
|
||||
Assert.Contains("Backend | C#, .NET", extracted);
|
||||
Assert.Contains("DevOps | Docker, Linux", extracted);
|
||||
Assert.Contains("\n\nExample Engineer\n- Built reliable services", extracted);
|
||||
Assert.Empty(extracted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_text_fallback_reads_bounded_utf8()
|
||||
{
|
||||
const string source = "# Ada Lovelace\n\n## Skills\nC#\nSQL";
|
||||
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source));
|
||||
var upload = new FormFile(stream, 0, stream.Length, "file", "resume.md");
|
||||
var method = typeof(ProfileCvController).GetMethod("ExtractPlainTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".md", CancellationToken.None }));
|
||||
|
||||
Assert.Equal(source, await task);
|
||||
}
|
||||
|
||||
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user