Skip to content

Commit 2ed9cec

Browse files
authored
Merge pull request #217 from bcgov/develop
Develop
2 parents c86c255 + 3834769 commit 2ed9cec

48 files changed

Lines changed: 1707 additions & 366 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend-services/src/document/document.service.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
import { UploadNormalizationLimiter } from "../upload/upload-normalization-limiter";
1010
import { DocumentService } from "./document.service";
1111
import { DocumentDbService } from "./document-db.service";
12-
import { PdfNormalizationService } from "./pdf-normalization.service";
12+
import {
13+
PdfNormalizationError,
14+
PdfNormalizationService,
15+
} from "./pdf-normalization.service";
1316

1417
describe("DocumentService", () => {
1518
let service: DocumentService;
@@ -235,6 +238,72 @@ describe("DocumentService", () => {
235238
expect(documentDbService.createDocument).toHaveBeenCalled();
236239
});
237240

241+
it("returns conversion_failed with the specific code/reason for a password-protected PDF", async () => {
242+
const pdfBytes = Buffer.from("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
243+
const base64 = pdfBytes.toString("base64");
244+
pdfNormalization.normalizeToPdf.mockRejectedValue(
245+
new PdfNormalizationError(
246+
"The PDF is password protected and cannot be processed. Upload an unlocked copy.",
247+
"password_protected",
248+
),
249+
);
250+
(documentDbService.createDocument as jest.Mock).mockImplementation(
251+
async (doc: { id: string; status: string }) => ({
252+
...doc,
253+
created_at: new Date(),
254+
updated_at: new Date(),
255+
}),
256+
);
257+
258+
const result = await service.uploadDocument(
259+
"Test",
260+
base64,
261+
"pdf",
262+
"encrypted.pdf",
263+
"test-model-id",
264+
"group-1",
265+
{},
266+
);
267+
268+
expect(result.kind).toBe("conversion_failed");
269+
if (result.kind !== "conversion_failed") throw new Error("unreachable");
270+
expect(result.code).toBe("password_protected");
271+
expect(result.reason).toMatch(/password protected/i);
272+
expect(result.document.status).toBe(DocumentStatus.conversion_failed);
273+
expect(result.document.normalized_file_path).toBeNull();
274+
});
275+
276+
it("returns generic conversion_failed for an unexpected normalization error", async () => {
277+
const pdfBytes = Buffer.from("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n");
278+
const base64 = pdfBytes.toString("base64");
279+
pdfNormalization.normalizeToPdf.mockRejectedValue(
280+
new Error("some internal boom"),
281+
);
282+
(documentDbService.createDocument as jest.Mock).mockImplementation(
283+
async (doc: { id: string; status: string }) => ({
284+
...doc,
285+
created_at: new Date(),
286+
updated_at: new Date(),
287+
}),
288+
);
289+
290+
const result = await service.uploadDocument(
291+
"Test",
292+
base64,
293+
"pdf",
294+
"file.pdf",
295+
"test-model-id",
296+
"group-1",
297+
{},
298+
);
299+
300+
expect(result.kind).toBe("conversion_failed");
301+
if (result.kind !== "conversion_failed") throw new Error("unreachable");
302+
// Internal error details are not leaked; falls back to the generic shape.
303+
expect(result.code).toBe("conversion_failed");
304+
expect(result.reason).toBe("Document could not be converted to PDF");
305+
});
306+
238307
it("should throw on invalid base64", async () => {
239308
await expect(
240309
service.uploadDocument(

apps/backend-services/src/document/document.service.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,14 @@ export interface UploadedDocument {
4747

4848
export type UploadDocumentResult =
4949
| { kind: "success"; document: UploadedDocument }
50-
| { kind: "conversion_failed"; document: UploadedDocument };
50+
| {
51+
kind: "conversion_failed";
52+
document: UploadedDocument;
53+
/** Machine-readable failure reason, e.g. `password_protected`. */
54+
code: string;
55+
/** Human-readable reason surfaced to the upload caller. */
56+
reason: string;
57+
};
5158

5259
@Injectable()
5360
export class DocumentService {
@@ -189,7 +196,18 @@ export class DocumentService {
189196
if (e instanceof BadRequestException) {
190197
throw e;
191198
}
192-
if (!(e instanceof PdfNormalizationError)) {
199+
200+
// Surface the specific reason only for our controlled normalization
201+
// errors. Unexpected errors fall back to the generic message/code so
202+
// internal details are never leaked to the caller.
203+
let code = "conversion_failed";
204+
let reason = "Document could not be converted to PDF";
205+
if (e instanceof PdfNormalizationError) {
206+
reason = e.message;
207+
if (e.code) {
208+
code = e.code;
209+
}
210+
} else {
193211
this.logger.error(
194212
`Unexpected normalization error: ${e instanceof Error ? e.message : String(e)}`,
195213
);
@@ -224,6 +242,8 @@ export class DocumentService {
224242
return {
225243
kind: "conversion_failed",
226244
document: this.toUploadedDocument(saved),
245+
code,
246+
reason,
227247
};
228248
}
229249

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { DocumentStatus } from "@generated/client";
2+
import { ApiProperty } from "@nestjs/swagger";
3+
4+
/** Response body for a successful document re-run (202 Accepted). */
5+
export class ReprocessDocumentResponseDto {
6+
@ApiProperty({ example: true })
7+
success!: boolean;
8+
9+
@ApiProperty({
10+
description: "Temporal workflow execution ID of the new run.",
11+
example: "graph-65fafe87-9fa7-46e6-9f1e-ba2ef326e3be",
12+
})
13+
workflowExecutionId!: string;
14+
15+
@ApiProperty({
16+
enum: DocumentStatus,
17+
description: "Document status after the re-run was started.",
18+
example: DocumentStatus.ongoing_ocr,
19+
})
20+
status!: DocumentStatus;
21+
}

apps/backend-services/src/document/pdf-normalization.service.spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,57 @@ describe("PdfNormalizationService", () => {
190190
});
191191
});
192192

193+
describe("normalizeToPdf – password-protected PDFs", () => {
194+
function mockNeedsPassword(needsPassword: boolean): void {
195+
jest.mocked(loadMupdf).mockResolvedValue({
196+
Document: {
197+
openDocument: jest.fn(() => ({
198+
needsPassword: () => needsPassword,
199+
})),
200+
},
201+
} as unknown as Awaited<ReturnType<typeof loadMupdf>>);
202+
}
203+
204+
afterEach(() => {
205+
jest.mocked(loadMupdf).mockReset();
206+
});
207+
208+
it("rejects a password-protected PDF as conversion failure (not OCR)", async () => {
209+
const buf = await minimalValidPdfBuffer();
210+
mockNeedsPassword(true);
211+
212+
await expect(service.normalizeToPdf(buf, "pdf")).rejects.toThrow(
213+
PdfNormalizationError,
214+
);
215+
});
216+
217+
it("includes a clear password-protected message", async () => {
218+
const buf = await minimalValidPdfBuffer();
219+
mockNeedsPassword(true);
220+
221+
await expect(service.normalizeToPdf(buf, "pdf")).rejects.toThrow(
222+
/password protected/i,
223+
);
224+
});
225+
226+
it("tags the error with a `password_protected` code", async () => {
227+
const buf = await minimalValidPdfBuffer();
228+
mockNeedsPassword(true);
229+
230+
const error = await service.normalizeToPdf(buf, "pdf").catch((e) => e);
231+
expect(error).toBeInstanceOf(PdfNormalizationError);
232+
expect((error as PdfNormalizationError).code).toBe("password_protected");
233+
});
234+
235+
it("allows a PDF that does not require a password", async () => {
236+
const buf = await minimalValidPdfBuffer();
237+
mockNeedsPassword(false);
238+
239+
const out = await service.normalizeToPdf(buf, "pdf");
240+
expect(out.equals(buf)).toBe(true);
241+
});
242+
});
243+
193244
describe("normalizeToPdf – PDF rotation baking", () => {
194245
async function pdfWithRotation(angle: number): Promise<Buffer> {
195246
const doc = await PDFDocument.create();

apps/backend-services/src/document/pdf-normalization.service.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ import sharp = require("sharp");
88

99
/** Thrown when a valid input could not be converted to PDF (distinct from invalid/corrupt upload). */
1010
export class PdfNormalizationError extends Error {
11-
constructor(message: string) {
11+
/**
12+
* Optional machine-readable reason surfaced to the upload caller (e.g.
13+
* `password_protected`). Defaults to undefined for generic conversion
14+
* failures, which the API reports as `conversion_failed`.
15+
*/
16+
readonly code?: string;
17+
18+
constructor(message: string, code?: string) {
1219
super(message);
1320
this.name = "PdfNormalizationError";
21+
this.code = code;
1422
}
1523
}
1624

@@ -233,7 +241,48 @@ export class PdfNormalizationService {
233241
* angle source is Tesseract OSD detection rather than the PDF /Rotate flag.
234242
* Keep both sites in sync when changing the math.
235243
*/
244+
/**
245+
* Rejects PDFs that require a password to open.
246+
*
247+
* The page-rotation load below uses `ignoreEncryption: true`, which silently
248+
* lets password-protected PDFs through normalization; Azure Document
249+
* Intelligence then rejects them with `400 UnsupportedContent` ("File may be
250+
* password protected"), stranding the document mid-pipeline. mupdf's
251+
* `needsPassword()` is the precise gate: it is true only for open-password
252+
* encryption, so permission-only encrypted PDFs (which OCR reads fine) are
253+
* deliberately allowed through. A throw here surfaces as `conversion_failed`,
254+
* keeping the document out of OCR entirely.
255+
*/
256+
private async assertPdfNotPasswordProtected(
257+
fileBuffer: Buffer,
258+
): Promise<void> {
259+
let requiresPassword: boolean;
260+
try {
261+
const mupdf = await loadMupdf();
262+
const doc = mupdf.Document.openDocument(
263+
new Uint8Array(fileBuffer),
264+
"application/pdf",
265+
);
266+
requiresPassword = doc.needsPassword();
267+
} catch (e) {
268+
// Not openable as a PDF for the probe — let the pdf-lib load below raise
269+
// the canonical "not a valid PDF or is corrupted" error instead.
270+
this.logger.debug("Password probe could not open PDF", {
271+
error: e instanceof Error ? e.message : String(e),
272+
});
273+
return;
274+
}
275+
if (requiresPassword) {
276+
throw new PdfNormalizationError(
277+
"The PDF is password protected and cannot be processed. Upload an unlocked copy.",
278+
"password_protected",
279+
);
280+
}
281+
}
282+
236283
private async normalizePdfPageRotations(fileBuffer: Buffer): Promise<Buffer> {
284+
await this.assertPdfNotPasswordProtected(fileBuffer);
285+
237286
let srcDoc: PDFDocument;
238287
try {
239288
srcDoc = await PDFDocument.load(fileBuffer, { ignoreEncryption: true });

apps/backend-services/src/ocr/ocr.controller.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { ForbiddenException, NotFoundException } from "@nestjs/common";
12
import { ConfigService } from "@nestjs/config";
23
import { Test, TestingModule } from "@nestjs/testing";
34
import { Request } from "express";
5+
import { DocumentService } from "@/document/document.service";
46
import { GroupRole } from "@/generated";
57
import { TrainingService } from "@/training/training.service";
68
import { OcrController } from "./ocr.controller";
9+
import { OcrService } from "./ocr.service";
710

811
/**
912
* Builds a request carrying an API-key-style resolved identity scoped to a
@@ -25,6 +28,12 @@ describe("OcrController", () => {
2528
const mockTrainingService = {
2629
findAllTrainedModelIds: jest.fn().mockResolvedValue([]),
2730
};
31+
const mockDocumentService = {
32+
findDocument: jest.fn(),
33+
};
34+
const mockOcrService = {
35+
reprocessDocument: jest.fn(),
36+
};
2837

2938
beforeEach(async () => {
3039
jest.clearAllMocks();
@@ -46,6 +55,8 @@ describe("OcrController", () => {
4655
provide: TrainingService,
4756
useValue: mockTrainingService,
4857
},
58+
{ provide: DocumentService, useValue: mockDocumentService },
59+
{ provide: OcrService, useValue: mockOcrService },
4960
],
5061
}).compile();
5162

@@ -101,6 +112,46 @@ describe("OcrController", () => {
101112
});
102113
});
103114

115+
describe("reprocessDocument", () => {
116+
const doc = { id: "doc-1", group_id: "group-1" };
117+
118+
it("throws NotFoundException when the document is missing", async () => {
119+
mockDocumentService.findDocument.mockResolvedValue(null);
120+
await expect(
121+
controller.reprocessDocument("doc-1", buildReq()),
122+
).rejects.toThrow(NotFoundException);
123+
expect(mockOcrService.reprocessDocument).not.toHaveBeenCalled();
124+
});
125+
126+
it("throws ForbiddenException when the caller is not a group member", async () => {
127+
mockDocumentService.findDocument.mockResolvedValue({
128+
...doc,
129+
group_id: "other-group",
130+
});
131+
await expect(
132+
controller.reprocessDocument("doc-1", buildReq("group-1")),
133+
).rejects.toThrow(ForbiddenException);
134+
expect(mockOcrService.reprocessDocument).not.toHaveBeenCalled();
135+
});
136+
137+
it("returns the 202 payload and delegates to OcrService on success", async () => {
138+
mockDocumentService.findDocument.mockResolvedValue(doc);
139+
mockOcrService.reprocessDocument.mockResolvedValue({
140+
workflowExecutionId: "graph-doc-1",
141+
status: "ongoing_ocr",
142+
});
143+
144+
const result = await controller.reprocessDocument("doc-1", buildReq());
145+
146+
expect(mockOcrService.reprocessDocument).toHaveBeenCalledWith(doc);
147+
expect(result).toEqual({
148+
success: true,
149+
workflowExecutionId: "graph-doc-1",
150+
status: "ongoing_ocr",
151+
});
152+
});
153+
});
154+
104155
describe("getModels with empty config", () => {
105156
it("should return default model when config is empty", async () => {
106157
const module: TestingModule = await Test.createTestingModule({
@@ -116,6 +167,8 @@ describe("OcrController", () => {
116167
provide: TrainingService,
117168
useValue: mockTrainingService,
118169
},
170+
{ provide: DocumentService, useValue: mockDocumentService },
171+
{ provide: OcrService, useValue: mockOcrService },
119172
],
120173
}).compile();
121174

0 commit comments

Comments
 (0)