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
@@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using System.IO.Compression; using System.IO.Compression;
using System.Text;
using JobTrackerApi.Controllers; using JobTrackerApi.Controllers;
using JobTrackerApi.Models; using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -241,7 +242,7 @@ public sealed class CvExtractionCoverageTests
} }
[Fact] [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(); await using var package = new MemoryStream();
using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true)) using (var archive = new ZipArchive(package, ZipArchiveMode.Create, leaveOpen: true))
@@ -263,14 +264,23 @@ public sealed class CvExtractionCoverageTests
} }
package.Position = 0; package.Position = 0;
var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx"); var upload = new FormFile(package, 0, package.Length, "file", "sanitized.docx");
var method = typeof(ProfileCvController).GetMethod("ExtractTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!; var method = typeof(ProfileCvController).GetMethod("ExtractPlainTextAsync", BindingFlags.NonPublic | BindingFlags.Static)!;
var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx" })); var task = Assert.IsAssignableFrom<Task<string>>(method.Invoke(null, new object[] { upload, ".docx", CancellationToken.None }));
var extracted = await task; var extracted = await task;
Assert.Contains("Technical Skills\n", extracted); Assert.Empty(extracted);
Assert.Contains("Backend | C#, .NET", extracted); }
Assert.Contains("DevOps | Docker, Linux", extracted);
Assert.Contains("\n\nExample Engineer\n- Built reliable services", 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) private static StructuredCvProfile InvokeProfileBuilder(string markdown)
@@ -1112,6 +1112,70 @@ public sealed class ProfileCvControllerTests
Assert.Equal("pending_review", run.Status); 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] [Fact]
public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels() public void Normalized_markdown_parse_preserves_real_estate_job_and_language_levels()
{ {
@@ -3,7 +3,6 @@ using System.Security.Cryptography;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Xml.Linq;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using JobTrackerApi.Models; using JobTrackerApi.Models;
@@ -1352,78 +1351,29 @@ public sealed partial class ProfileCvController : ControllerBase
|| Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)"); || Regex.IsMatch(text, @"(?im)^\s*#\s*(Contact|Professional Summary|Summary|Work Experience|Experience|Education|Skills|Languages|Interests)");
} }
private static async Task<string> ExtractTextAsync(IFormFile file, string extension) private static async Task<string> ExtractPlainTextAsync(IFormFile file, string extension, CancellationToken cancellationToken)
{ {
if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) return string.Empty;
using var stream = file.OpenReadStream();
using var reader = new StreamReader(
stream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true),
detectEncodingFromByteOrderMarks: true);
var buffer = new char[8192];
var result = new StringBuilder(capacity: (int)Math.Min(file.Length, 64 * 1024));
while (true)
{ {
using var stream = file.OpenReadStream(); var read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); if (read == 0) break;
return (await reader.ReadToEndAsync()).Trim(); if (result.Length + read > 200_000)
}
await using var memory = new MemoryStream();
await file.CopyToAsync(memory);
var bytes = memory.ToArray();
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
{
var raw = Encoding.Latin1.GetString(bytes);
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
.Select(match => match.Groups[1].Value)
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
.SelectMany(match => Regex.Matches(match.Groups[1].Value, @"\((.*?)\)", RegexOptions.Singleline).Select(x => x.Groups[1].Value)))
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => Regex.Unescape(value))
.ToList();
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
var scrubbed = Regex.Replace(joined, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
}
if (string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase))
{
using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(bytes), System.IO.Compression.ZipArchiveMode.Read, leaveOpen: false);
var entry = archive.GetEntry("word/document.xml");
if (entry is null) return string.Empty;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream, Encoding.UTF8);
var xml = await reader.ReadToEndAsync();
var document = XDocument.Parse(xml, LoadOptions.PreserveWhitespace);
XNamespace word = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
var body = document.Root?.Element(word + "body");
if (body is null) return string.Empty;
static string Text(XElement element, XNamespace ns) => string.Concat(
element.Descendants(ns + "t").Select(node => node.Value));
var blocks = new List<string>();
foreach (var block in body.Elements())
{ {
if (block.Name == word + "p") throw new InvalidOperationException("The extracted CV text is too large to process safely.");
{
var paragraph = Text(block, word).Trim();
if (paragraph.Length == 0) continue;
var style = block.Element(word + "pPr")?.Element(word + "pStyle")?.Attribute(word + "val")?.Value ?? string.Empty;
if (style.Contains("Role", StringComparison.OrdinalIgnoreCase) && blocks.Count > 0) blocks.Add(string.Empty);
blocks.Add(style.Contains("Bullet", StringComparison.OrdinalIgnoreCase) ? $"- {paragraph}" : paragraph);
continue;
}
if (block.Name != word + "tbl") continue;
foreach (var row in block.Elements(word + "tr"))
{
var cells = row.Elements(word + "tc")
.Select(cell => string.Join(" ", cell.Elements(word + "p").Select(paragraph => Text(paragraph, word).Trim()).Where(value => value.Length > 0)))
.Where(value => value.Length > 0)
.ToList();
if (cells.Count > 0) blocks.Add(string.Join(" | ", cells));
}
} }
result.Append(buffer, 0, read);
return string.Join("\n", blocks).Trim();
} }
return string.Empty; return result.ToString().Trim();
} }
} }
@@ -231,11 +231,15 @@ public sealed partial class ProfileCvController : ControllerBase
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
text = (await ExtractTextAsync(file, extension)).Trim(); text = (await ExtractPlainTextAsync(file, extension, cancellationToken)).Trim();
} }
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
throw new InvalidOperationException("The uploaded CV file could not be read or was empty."); throw new InvalidOperationException(
string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
? "The uploaded CV file could not be read or was empty."
: "The document extraction service could not read this CV safely.");
} }
text = RepairKnownMojibake(text); text = RepairKnownMojibake(text);
@@ -506,8 +510,12 @@ public sealed partial class ProfileCvController : ControllerBase
{ {
var generationFailure = ex as AiGenerationException; var generationFailure = ex as AiGenerationException;
var retryable = generationFailure?.Retryable == true; var retryable = generationFailure?.Retryable == true;
var failureMessage = generationFailure?.Message
?? (ex is InvalidOperationException
? ex.Message
: "CV processing failed unexpectedly. Please try again.");
run.Status = retryable ? "queued" : "failed"; run.Status = retryable ? "queued" : "failed";
run.ErrorMessage = ex.Message; run.ErrorMessage = failureMessage;
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow; run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
if (!retryable) if (!retryable)
@@ -519,7 +527,7 @@ public sealed partial class ProfileCvController : ControllerBase
return new CvProcessingOutcome( return new CvProcessingOutcome(
false, false,
generationFailure?.Category ?? "cv_processing_failed", generationFailure?.Category ?? "cv_processing_failed",
ex.Message, failureMessage,
retryable, retryable,
generationFailure?.Provider, generationFailure?.Provider,
generationFailure?.Model, generationFailure?.Model,
+1
View File
@@ -221,3 +221,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree | | V-187 | Focused public-CV/rate-key tests, focused authenticated-preview Jest, full backend, production frontend build and tracked-tree secret-pattern scan | Repository root / `job-tracker-ui` | Close the remaining low-risk public PDF, preview sandbox and tracked JWT hardening findings | PASS — backend focused 4/4 and full 681/681; preview Jest 15/15; production build passes. Two clients receive independent same-slug PDF partitions, both authenticated preview iframes disable scripts with a sandbox, and the values-suppressed tracked-tree scan finds no JWT/private-key pattern outside audit/operations records | No stress test, production request, history rewrite or Data Protection inspection. A coordinated history rewrite remains outside this change | JT-023/JT-025 current-tree gaps closed; expired JT-020 artifact removed from current tree |
| V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence | | V-188 | 75-user query-count fixture, 205-message/two-tenant pagination fixture, focused/full backend and frontend, optimized build | Repository root / `job-tracker-ui` | Remove JT-021's confirmed N+1 and silent 200-message ceiling without breaking old clients | PASS — admin list performs two reads independent of 75 users; page 3 returns the final 5 of 205 owned messages and excludes another tenant. Focused backend 9/9, correspondence UI 15/15, backend 683/683, frontend 58 suites/239 tests and build pass. UI page navigation and filter reset are covered; the legacy inbox endpoint remains unchanged | Synthetic SQLite/InMemory/JSDOM only; no provider mailbox, production dataset or p95 load test. Current page linked/inbound chips intentionally describe the visible page | JT-021 repository defects closed; provider/production capacity remains operational evidence |
| V-189 | Fresh Python 3.12 virtual environments; `pytest -q`; `npm run lint`; full frontend Jest; `npm run build`; `npm audit --audit-level=moderate` | Repository root / `tools/summarizer` / `job-tracker-ui` | Restore the local Python test environment and establish a reproducible zero-warning frontend lint gate | PASS — Python 3.12.10; sidecar 26/26; ESLint zero findings; frontend 64 suites/272 tests; optimized build/TypeScript; npm audit zero vulnerabilities | Five existing SWIG deprecation warnings; Jest retains its documented force-exit/open-handle notice. ESLint 9 is intentionally pinned because the Next 16 React plugin is not ESLint 10 compatible | Local Python/frontend quality gates restored; parser dependency remediation proceeds separately under SEC-006 | | V-189 | Fresh Python 3.12 virtual environments; `pytest -q`; `npm run lint`; full frontend Jest; `npm run build`; `npm audit --audit-level=moderate` | Repository root / `tools/summarizer` / `job-tracker-ui` | Restore the local Python test environment and establish a reproducible zero-warning frontend lint gate | PASS — Python 3.12.10; sidecar 26/26; ESLint zero findings; frontend 64 suites/272 tests; optimized build/TypeScript; npm audit zero vulnerabilities | Five existing SWIG deprecation warnings; Jest retains its documented force-exit/open-handle notice. ESLint 9 is intentionally pinned because the Next 16 React plugin is not ESLint 10 compatible | Local Python/frontend quality gates restored; parser dependency remediation proceeds separately under SEC-006 |
| V-190 | Compatible parser dependency resolution; Linux CPU hash lock; clean hash install; `pip-audit`; generated parser boundary tests; focused/full backend and sidecar suites; Docker daemon probe | Repository root / `tools/summarizer` | Remove reachable upload-parser advisories and close unsafe fallback/resource-boundary paths without parsing hostile fixtures | PASS/PARTIAL — FastAPI 0.141.1/Starlette 1.6.0, Pillow 12.3.0, pypdf 6.16.2 and python-multipart 0.0.32 resolve and install from hashes; upload-facing packages audit clear; parser 32/32, focused backend 51/51 and backend 719/719. Signature/container/page/pixel/decompression/output limits pass; backend binary fallback is removed and unexpected failures are sanitized | Docker daemon unavailable, so production image smoke/container assertions did not run. `pip-audit` reports 45 Torch/Transformers model-stack advisories tracked separately under JT-017. One Starlette/httpx test-client warning and five SWIG warnings remain | SEC-006 implemented locally; SEC-007 boundary work in progress, with child-process and container isolation still required |
@@ -0,0 +1,40 @@
# SEC-006 parser dependency verification
## Result
The libraries reached by untrusted CV upload parsing were upgraded to compatible fixed releases and locked with hashes for the Linux CPU image. The dependency-only package is implemented locally but is not production-verified because the local Docker Desktop daemon is unavailable.
## Fixed parser stack
- Python base: `3.12.10-slim-bookworm`
- FastAPI: `0.141.1`
- Starlette: `1.6.0`
- Pillow: `12.3.0`
- pypdf: `6.16.2`
- python-multipart: `0.0.32`
- Reproducible Linux CPU dependency set: `tools/summarizer/requirements-linux.lock`
The lock was generated for Python 3.12 on `x86_64-unknown-linux-gnu` with the PyTorch CPU index and contains hashes for every resolved package. A clean local environment installed the generated set with `--require-hashes` and imported the upgraded parser packages successfully.
## Advisory review
`pip-audit -r requirements.txt` reports no advisory for FastAPI, Starlette, Pillow, pypdf, PyMuPDF, python-docx, pytesseract or python-multipart. It reports 45 advisories across `torch==2.6.0` and `transformers==4.48.3`.
Those remaining findings belong to the model-loading stack rather than the CV file decoder paths remediated by SEC-006. They remain open under JT-017; this record does not accept or close them.
## Regression proof
- Python parser suite: 32/32 passed.
- Focused CV extraction/backend suite: 51/51 passed.
- Full backend suite: 719/719 passed.
- Generated boundary cases cover mismatched signatures, invalid text bytes, excessive PDF pages, excessive image pixels, excessive DOCX entries and sanitized unexpected parser failures.
- The API no longer performs local PDF, DOCX or image parsing when the sidecar returns no text. A bounded UTF-8 text/Markdown fallback remains.
## Remaining verification
`docker info` fails because `dockerDesktopLinuxEngine` is not running. When a daemon is available:
1. Build `tools/summarizer/Dockerfile` without changing the lock.
2. Start the image with the intended SEC-007 runtime restrictions.
3. Run health plus benign TXT, Markdown, PDF, DOCX and image extraction smoke tests.
4. Record the image digest and measured memory/CPU use before any production activation.
+6 -6
View File
@@ -44,7 +44,7 @@ Updated: 2026-08-30
### In progress ### In progress
- SEC-006 parser dependency remediation is unblocked now that package-index access has been explicitly authorized. Resolve a compatible fixed parser stack, audit it, and rerun the benign extraction corpus before starting SEC-007 isolation. - SEC-007 bounded document processing. Upload-facing parser dependencies are fixed and hash-locked; the sidecar now rejects signature mismatches, decompression/page/pixel/output excesses, the backend no longer retries PDF/DOCX/image parsing in-process, and unexpected parser details are sanitized. Child-process deadline/resource isolation and container controls remain.
### Remaining ### Remaining
@@ -108,17 +108,17 @@ Updated: 2026-08-30
- Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate. - Playwright: 8/10 passed on the first complete run; both failures were ambiguous selectors in the newly responsive Career selector, not product failures. Both corrected focused regressions now pass (2/2); final full rerun remains in the end-of-batch gate.
- Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review. - Manual desktop browser review: webpack development server rendered the new Career navigation and Overview correctly in dark mode; API-dependent profile status remained unavailable because the backend was not running for that isolated UI review.
- **Overall programme status:** Active but externally blocked. Eight packages are locally verified and twenty-five are implemented with verification incomplete. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch. - **Overall programme status:** Active but externally blocked. Eight packages are locally verified, twenty-six are implemented with verification incomplete, and SEC-007 is in progress. The prioritized admin-only version indicator, every immediate repository/browser item, SEC-009, the PROD-001 read-only inventory, and the PROD-003 safe benchmark harness are complete on the release branch.
- **Current work package:** SEC-006 parser dependency remediation; package-index access is now authorized. - **Current work package:** SEC-007 bounded isolated document processing; SEC-006 is implemented locally and awaits Docker image smoke.
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates. - **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002, DEP-001 and VER-001 (`VERIFIED LOCALLY`). - **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002, DEP-001 and VER-001 (`VERIFIED LOCALLY`).
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, SEC-009, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider/retention gates remain where recorded. - **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-006, SEC-008, SEC-009, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider/retention gates remain where recorded.
- **Production-verified work:** None. - **Production-verified work:** None.
- **Blocked work:** PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification. - **Blocked work:** PROD-001/003/004 and REL-001 require network/backup/model/deployment authority and unfinished dependencies. Real provider and live deletion/restore checks remain gated; DEP-001 awaits approved merge/live verification.
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages. - **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
- **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain. - **Immediate order:** all sixteen immediate repository items are complete locally, including the original UI/release queue plus SEC-009 cache/tombstone safety, worker restart clocks, universal AI accounting, email-token/Stripe lifecycle tests, exhaustive Job email selectors, the repaired migration chain, CV/public-edge hardening and measured admin/mail scaling. PROD-001 read-only evidence and the PROD-003 plan-only harness are also complete. The final audit is checking tooling/documentation before declaring only external blockers remain.
- **Status counts:** 8 `VERIFIED LOCALLY`; 25 `IMPLEMENTED — NOT VERIFIED`; 0 `IN PROGRESS`; 1 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`. - **Status counts:** 8 `VERIFIED LOCALLY`; 26 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 0 `NOT STARTED`; 4 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
- **Test status:** backend 683/683; frontend 58/58 suites and 239/239 tests; AI sidecar 23/23; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit 0 evidence remains current because the lockfile did not change. Jest's slow/open-handle behavior remains recorded. - **Test status:** backend 719/719; frontend 64/64 suites and 272/272 tests; ESLint zero findings; AI sidecar 32/32; Ollama benchmark harness 5/5 plus safe dry-run; optimized production build/TypeScript; EF model parity; SQLite/MariaDB migration scripts; blank/idempotent/populated SQLite migration-chain tests; disposable fresh/restarted MariaDB 11.8 application startup; Docker Compose config; safe-failure deployment preflight; and Playwright 9/9. npm audit remains at zero. The parser image build is pending because Docker Desktop's daemon is offline. Jest's slow/open-handle behavior and six Python deprecation warnings remain recorded.
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default. - **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
- **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers. - **Production status:** State unchanged. Sanitized read-only SSH inventory was performed; no logs, prompts, private rows/content or secret values were read, and no provider/model call, model pull, service restart, file/config change, backup, restore, migration or deployment occurred. It confirmed all-interface Ollama/frontend listeners and stale database-only backups as rollout blockers.
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt. - **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. The direct clean EF-only SQLite defect and synchronous AI accounting gap are closed; migration/reconciler dual ownership remains architectural debt.
+8 -8
View File
@@ -222,11 +222,11 @@ This queue records the highest-value work that can proceed without production cr
- **Required tests:** dependency resolution/audit, Python suite, generated benign corpus. - **Required tests:** dependency resolution/audit, Python suite, generated benign corpus.
- **Required browser verification:** none for dependency-only package. - **Required browser verification:** none for dependency-only package.
- **Required production verification:** image digest and smoke before activation. - **Required production verification:** image digest and smoke before activation.
- **Status:** `BLOCKED`. - **Status:** `IMPLEMENTED — NOT VERIFIED`.
- **Blocker:** repository instructions prohibit internet/package resolution without explicit permission; fixed-version compatibility cannot be resolved or verified offline. - **Blocker:** Docker Desktop is installed but its Linux daemon is offline, so the hash-locked production image has not been built locally.
- **Evidence:** audit lists reachable Pillow/pypdf/multipart/Starlette advisories and compatibility conflict. - **Evidence:** `docs/verification/sec-006-parser-dependencies.md`; upload-facing dependency audit is clear; clean hash install succeeds; parser suite 32/32; backend 719/719.
- **Commit:** none. - **Commit:** none.
- **Remaining work:** explicit package-index permission, compatible fixed-version resolution, lock/hash refresh, audit, benign corpus parity and image smoke; do not execute malicious files. - **Remaining work:** build the production image from `requirements-linux.lock` and run its health/benign extraction smoke when a Docker daemon is available. Torch/Transformers advisories remain separately tracked under JT-017 and are not accepted as upload-parser findings.
### SEC-007 — Bounded isolated document processing ### SEC-007 — Bounded isolated document processing
@@ -240,11 +240,11 @@ This queue records the highest-value work that can proceed without production cr
- **Required tests:** generated boundary/corrupt fixtures, harmless sleeping child, cancellation/restart cleanup, outage/no-fallback, container assertions. - **Required tests:** generated boundary/corrupt fixtures, harmless sleeping child, cancellation/restart cleanup, outage/no-fallback, container assertions.
- **Required browser verification:** synthetic CV upload status/failure; authorized private CV local-only only after safeguards. - **Required browser verification:** synthetic CV upload status/failure; authorized private CV local-only only after safeguards.
- **Required production verification:** measured memory/CPU limits and canary synthetic extraction. - **Required production verification:** measured memory/CPU limits and canary synthetic extraction.
- **Status:** `NOT STARTED`. - **Status:** `IN PROGRESS`.
- **Blocker:** follows dependency update; production sizing requires access. - **Blocker:** production sizing requires access; local Docker container assertions require a running daemon.
- **Evidence:** audit parser call path and limits design. - **Evidence:** signature/container validation, page/pixel/decompression/output ceilings and stable parser errors have boundary tests; backend binary fallback is removed and raw unexpected failures are sanitized. Focused backend 51/51, full backend 719/719 and parser 32/32 pass.
- **Commit:** none. - **Commit:** none.
- **Remaining work:** split behavioral and container commits if needed. - **Remaining work:** move decode into a deadline-bounded child process/process group, add descendant termination and cleanup tests, bound scheduling/backpressure, then apply and verify non-root/read-only/PID/CPU/memory/tmpfs container controls.
### SEC-008 — Recoverable attachment mutations ### SEC-008 — Recoverable attachment mutations
+3 -3
View File
@@ -1,4 +1,4 @@
FROM python:3.11 FROM python:3.12.10-slim-bookworm
ENV PIP_NO_CACHE_DIR=1 \ ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -8,9 +8,9 @@ WORKDIR /app
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \ && apt-get install -y --no-install-recommends tesseract-ocr tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./ COPY requirements-linux.lock ./
RUN python -m pip install --upgrade pip setuptools wheel \ RUN python -m pip install --upgrade pip setuptools wheel \
&& python -m pip install --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt && python -m pip install --require-hashes --extra-index-url https://download.pytorch.org/whl/cpu -r requirements-linux.lock
COPY . . COPY . .
EXPOSE 8001 EXPOSE 8001
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"] CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
+2 -2
View File
@@ -17,7 +17,7 @@ Windows:
```powershell ```powershell
python -m venv .venv python -m venv .venv
.\.venv\Scripts\Activate.ps1 .\.venv\Scripts\Activate.ps1
pip install -r requirements.txt pip install -r requirements-dev.txt
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1 python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
``` ```
@@ -26,7 +26,7 @@ Linux / macOS:
```bash ```bash
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements-dev.txt
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1 python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
``` ```
+99 -8
View File
@@ -17,6 +17,7 @@ import torch
import pytesseract import pytesseract
import threading import threading
import time import time
import zipfile
from urllib import request as urllib_request from urllib import request as urllib_request
from urllib.error import URLError, HTTPError from urllib.error import URLError, HTTPError
from contextvars import ContextVar from contextvars import ContextVar
@@ -91,7 +92,17 @@ async def require_service_token(request: Request, call_next):
MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MODEL_NAME = "sshleifer/distilbart-cnn-12-6"
MAX_INPUT_CHARS = 20000 MAX_INPUT_CHARS = 20000
MAX_CONTEXT_CHARS = 2200 MAX_CONTEXT_CHARS = 2200
MAX_EXTRACT_FILE_BYTES = 8 * 1024 * 1024 MAX_EXTRACT_FILE_BYTES = 5 * 1024 * 1024
MAX_EXTRACT_CHARS = 200_000
MAX_PDF_PAGES = 40
MAX_DOCX_ENTRIES = 256
MAX_DOCX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
MAX_DOCX_ENTRY_BYTES = 8 * 1024 * 1024
MAX_DOCX_COMPRESSION_RATIO = 100
MAX_IMAGE_DIMENSION = 12_000
MAX_IMAGE_PIXELS = 40_000_000
MAX_PDF_PAGE_PIXELS = 12_000_000
MAX_PDF_OCR_PIXELS = 120_000_000
OCR_LANGUAGES = "eng" OCR_LANGUAGES = "eng"
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/") OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
@@ -1068,36 +1079,110 @@ def _ocr_image(image: Image.Image) -> str:
return _normalize_text(text) return _normalize_text(text)
def _check_extracted_size(text: str) -> str:
if len(text) > MAX_EXTRACT_CHARS:
raise HTTPException(status_code=422, detail="The document contains too much extracted text.")
return text
def _check_image_size(image: Image.Image, *, pixel_limit: int | None = None) -> None:
pixel_limit = MAX_IMAGE_PIXELS if pixel_limit is None else pixel_limit
width, height = image.size
if width <= 0 or height <= 0 or width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
raise HTTPException(status_code=422, detail="The document image dimensions are not supported.")
if width * height > pixel_limit:
raise HTTPException(status_code=422, detail="The document image contains too many pixels.")
if getattr(image, "n_frames", 1) != 1:
raise HTTPException(status_code=422, detail="Multi-frame document images are not supported.")
def _validate_docx_container(data: bytes) -> None:
try:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entries = archive.infolist()
names = {entry.filename for entry in entries}
if "[Content_Types].xml" not in names or "word/document.xml" not in names:
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.")
if len(entries) > MAX_DOCX_ENTRIES:
raise HTTPException(status_code=422, detail="The DOCX contains too many files.")
total_size = 0
for entry in entries:
total_size += entry.file_size
if entry.file_size > MAX_DOCX_ENTRY_BYTES:
raise HTTPException(status_code=422, detail="The DOCX contains an oversized file.")
if entry.file_size > 0 and entry.compress_size == 0:
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
if entry.compress_size > 0 and entry.file_size / entry.compress_size > MAX_DOCX_COMPRESSION_RATIO:
raise HTTPException(status_code=422, detail="The DOCX compression ratio is not supported.")
if total_size > MAX_DOCX_UNCOMPRESSED_BYTES:
raise HTTPException(status_code=422, detail="The DOCX expands beyond the supported size.")
except HTTPException:
raise
except (zipfile.BadZipFile, OSError) as exc:
raise HTTPException(status_code=400, detail="The uploaded file does not match its DOCX extension.") from exc
def _validate_file_signature(extension: str, data: bytes) -> None:
matches = {
".pdf": data.startswith(b"%PDF-"),
".docx": data.startswith(b"PK"),
".png": data.startswith(b"\x89PNG\r\n\x1a\n"),
".jpg": data.startswith(b"\xff\xd8\xff"),
".jpeg": data.startswith(b"\xff\xd8\xff"),
".webp": len(data) >= 12 and data.startswith(b"RIFF") and data[8:12] == b"WEBP",
}
if extension in matches and not matches[extension]:
raise HTTPException(status_code=400, detail=f"The uploaded file does not match its {extension[1:].upper()} extension.")
if extension in {".txt", ".md"} and b"\x00" in data:
raise HTTPException(status_code=400, detail="The uploaded text file contains binary data.")
def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]: def _extract_pdf_text(data: bytes) -> tuple[str, bool, int]:
page_count = 0 page_count = 0
extracted_pages = [] extracted_pages = []
try: try:
reader = PdfReader(io.BytesIO(data)) reader = PdfReader(io.BytesIO(data))
page_count = len(reader.pages) page_count = len(reader.pages)
if page_count > MAX_PDF_PAGES:
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
for page in reader.pages: for page in reader.pages:
try: try:
extracted_pages.append(page.extract_text(extraction_mode="layout") or "") extracted_pages.append(page.extract_text(extraction_mode="layout") or "")
except TypeError: except TypeError:
extracted_pages.append(page.extract_text() or "") extracted_pages.append(page.extract_text() or "")
except HTTPException:
raise
except Exception: except Exception:
extracted_pages = [] extracted_pages = []
text = _normalize_text("\n".join(extracted_pages)) text = _check_extracted_size(_normalize_text("\n".join(extracted_pages)))
if len(text) >= 80: if len(text) >= 80:
return text, False, page_count return text, False, page_count
doc = fitz.open(stream=data, filetype="pdf") doc = fitz.open(stream=data, filetype="pdf")
page_count = doc.page_count page_count = doc.page_count
if page_count > MAX_PDF_PAGES:
doc.close()
raise HTTPException(status_code=422, detail="The PDF contains too many pages.")
ocr_pages = [] ocr_pages = []
total_pixels = 0
for page in doc: for page in doc:
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False) pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
page_pixels = pix.width * pix.height
total_pixels += page_pixels
if page_pixels > MAX_PDF_PAGE_PIXELS or total_pixels > MAX_PDF_OCR_PIXELS:
doc.close()
raise HTTPException(status_code=422, detail="The PDF requires too much OCR processing.")
image = Image.open(io.BytesIO(pix.tobytes("png"))) image = Image.open(io.BytesIO(pix.tobytes("png")))
_check_image_size(image, pixel_limit=MAX_PDF_PAGE_PIXELS)
ocr_pages.append(_ocr_image(image)) ocr_pages.append(_ocr_image(image))
_check_extracted_size("\n".join(ocr_pages))
doc.close() doc.close()
return _normalize_text("\n".join(ocr_pages)), True, page_count return _check_extracted_size(_normalize_text("\n".join(ocr_pages))), True, page_count
def _extract_docx_text(data: bytes) -> str: def _extract_docx_text(data: bytes) -> str:
_validate_docx_container(data)
document = Document(io.BytesIO(data)) document = Document(io.BytesIO(data))
parts = [] parts = []
blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs blocks = document.iter_inner_content() if hasattr(document, "iter_inner_content") else document.paragraphs
@@ -1113,11 +1198,15 @@ def _extract_docx_text(data: bytes) -> str:
if "role" in style and parts: if "role" in style and parts:
parts.append("") parts.append("")
parts.append(f"- {text}" if "bullet" in style else text) parts.append(f"- {text}" if "bullet" in style else text)
return _normalize_text("\n".join(parts)) return _check_extracted_size(_normalize_text("\n".join(parts)))
def _extract_plain_text(data: bytes) -> str: def _extract_plain_text(data: bytes) -> str:
return _normalize_text(data.decode("utf-8", errors="ignore")) try:
decoded = data.decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise HTTPException(status_code=400, detail="The uploaded text file is not valid UTF-8.") from exc
return _check_extracted_size(_normalize_text(decoded))
@app.post("/extract-text") @app.post("/extract-text")
@@ -1135,6 +1224,9 @@ async def extract_text(file: UploadFile = File(...)):
raise HTTPException(status_code=400, detail="The uploaded file was empty.") raise HTTPException(status_code=400, detail="The uploaded file was empty.")
if len(data) > MAX_EXTRACT_FILE_BYTES: if len(data) > MAX_EXTRACT_FILE_BYTES:
raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.") raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.")
if extension not in {".txt", ".md", ".docx", ".pdf", *IMAGE_EXTENSIONS}:
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
_validate_file_signature(extension, data)
try: try:
if extension in {".txt", ".md"}: if extension in {".txt", ".md"}:
@@ -1149,15 +1241,14 @@ async def extract_text(file: UploadFile = File(...)):
text, ocr_used, page_count = _extract_pdf_text(data) text, ocr_used, page_count = _extract_pdf_text(data)
elif extension in IMAGE_EXTENSIONS: elif extension in IMAGE_EXTENSIONS:
image = Image.open(io.BytesIO(data)) image = Image.open(io.BytesIO(data))
_check_image_size(image)
text = _ocr_image(image) text = _ocr_image(image)
ocr_used = True ocr_used = True
page_count = 1 page_count = 1
else:
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
except HTTPException: except HTTPException:
raise raise
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI extraction failed: {exc}") from exc raise HTTPException(status_code=422, detail="Document extraction failed.") from exc
if not text: if not text:
raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.") raise HTTPException(status_code=422, detail="AI extraction did not find readable text in the uploaded file.")
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,12 +1,13 @@
fastapi==0.115.12 fastapi==0.141.1
starlette==1.6.0
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
transformers==4.48.3 transformers==4.48.3
cachetools==5.5.2 cachetools==5.5.2
pydantic==2.10.6 pydantic==2.10.6
torch==2.6.0 torch==2.6.0
pillow==11.1.0 pillow==12.3.0
pytesseract==0.3.13 pytesseract==0.3.13
pypdf==5.4.0 pypdf==6.16.2
pymupdf==1.25.5 pymupdf==1.25.5
python-docx==1.1.2 python-docx==1.1.2
python-multipart==0.0.20 python-multipart==0.0.32
+83
View File
@@ -2,9 +2,12 @@ import importlib
import io import io
import json import json
import sys import sys
import zipfile
from pathlib import Path from pathlib import Path
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from PIL import Image
from pypdf import PdfWriter
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -523,6 +526,86 @@ def test_extract_text_rejects_oversized_upload_before_parsing(monkeypatch):
assert "too large" in response.json()["detail"].lower() assert "too large" in response.json()["detail"].lower()
def test_extract_text_rejects_extension_signature_mismatch(monkeypatch):
module = load_app_module(monkeypatch)
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.pdf", b"not a pdf", "application/pdf")})
assert response.status_code == 400
assert response.json()["detail"] == "The uploaded file does not match its PDF extension."
def test_extract_text_rejects_binary_plain_text(monkeypatch):
module = load_app_module(monkeypatch)
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.txt", b"Name\x00binary", "text/plain")})
assert response.status_code == 400
assert response.json()["detail"] == "The uploaded text file contains binary data."
def test_extract_text_rejects_pdf_over_page_limit(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_PDF_PAGES", 1)
writer = PdfWriter()
writer.add_blank_page(width=100, height=100)
writer.add_blank_page(width=100, height=100)
payload = io.BytesIO()
writer.write(payload)
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.pdf", payload.getvalue(), "application/pdf")})
assert response.status_code == 422
assert response.json()["detail"] == "The PDF contains too many pages."
def test_extract_text_rejects_image_pixel_limit_before_ocr(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_IMAGE_PIXELS", 50)
monkeypatch.setattr(module, "_ocr_image", lambda image: (_ for _ in ()).throw(AssertionError("OCR must not run")))
payload = io.BytesIO()
Image.new("RGB", (10, 10), "white").save(payload, format="PNG")
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.png", payload.getvalue(), "image/png")})
assert response.status_code == 422
assert response.json()["detail"] == "The document image contains too many pixels."
def test_docx_container_rejects_excessive_entries(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "MAX_DOCX_ENTRIES", 2)
payload = io.BytesIO()
with zipfile.ZipFile(payload, "w") as archive:
archive.writestr("[Content_Types].xml", "types")
archive.writestr("word/document.xml", "document")
archive.writestr("word/styles.xml", "styles")
try:
module._validate_docx_container(payload.getvalue())
except module.HTTPException as exc:
assert exc.status_code == 422
assert exc.detail == "The DOCX contains too many files."
else:
raise AssertionError("Expected the DOCX entry limit to reject the container")
def test_extract_text_does_not_leak_parser_exception(monkeypatch):
module = load_app_module(monkeypatch)
monkeypatch.setattr(module, "_extract_pdf_text", lambda data: (_ for _ in ()).throw(RuntimeError("private path C:/secret")))
client = TestClient(module.app)
response = client.post("/extract-text", files={"file": ("resume.pdf", b"%PDF-invalid", "application/pdf")})
assert response.status_code == 422
assert response.json()["detail"] == "Document extraction failed."
assert "secret" not in response.text
def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch): def test_extraction_normalization_preserves_sections_and_bullets(monkeypatch):
module = load_app_module(monkeypatch) module = load_app_module(monkeypatch)