mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { env } from "../../../../apps/api/src/config.js";
|
|
import { objectExists } from "../../../../apps/api/src/lib/object-storage.js";
|
|
import { DocumentInputHandler } from "../../../../apps/api/src/modality/document-input.js";
|
|
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../../test-server.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
flowAdd: vi.fn(),
|
|
ocrUploadLimitsOverride: null as { fileBytes: number; aggregateBytes: number } | null,
|
|
}));
|
|
|
|
vi.mock("../../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../../../apps/api/src/jobs/enqueue.js")>();
|
|
return {
|
|
...actual,
|
|
getFlowProducer: () => ({ add: mocks.flowAdd }),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../../../apps/api/src/lib/ocr-limits.js", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<typeof import("../../../../apps/api/src/lib/ocr-limits.js")>();
|
|
return {
|
|
...actual,
|
|
resolveOcrUploadLimits: (maxUploadSizeMb: number) =>
|
|
mocks.ocrUploadLimitsOverride ?? actual.resolveOcrUploadLimits(maxUploadSizeMb),
|
|
};
|
|
});
|
|
|
|
const INVALID_PDF = Buffer.from("this is deliberately not a PDF");
|
|
const VALID_PDF = readFixture(fixtures.document.pdf3);
|
|
const ENCRYPTED_PDF = readFixture(fixtures.document.encrypted);
|
|
const OCR_PIPELINE = JSON.stringify({
|
|
steps: [{ toolId: "ocr-pdf", settings: { quality: "fast", pages: "1" } }],
|
|
});
|
|
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
let scratchRoot: string;
|
|
let originalScratchPath: string;
|
|
let prepareSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeAll(async () => {
|
|
scratchRoot = await mkdtemp(join(tmpdir(), "snapotter-ocr-pdf-routes-"));
|
|
originalScratchPath = env.SCRATCH_PATH;
|
|
env.SCRATCH_PATH = scratchRoot;
|
|
prepareSpy = vi.spyOn(DocumentInputHandler.prototype, "prepare");
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
prepareSpy.mockRestore();
|
|
env.SCRATCH_PATH = originalScratchPath;
|
|
await testApp.cleanup();
|
|
await rm(scratchRoot, { recursive: true, force: true });
|
|
}, 10_000);
|
|
|
|
beforeEach(() => {
|
|
mocks.flowAdd.mockReset();
|
|
mocks.flowAdd.mockResolvedValue(undefined);
|
|
mocks.ocrUploadLimitsOverride = null;
|
|
prepareSpy.mockClear();
|
|
});
|
|
|
|
async function postMultipart(url: string, parts: Parameters<typeof createMultipartPayload>[0]) {
|
|
const { body, contentType } = createMultipartPayload(parts);
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url,
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
await vi.waitFor(async () => expect(await readdir(scratchRoot)).toEqual([]), {
|
|
timeout: 1_000,
|
|
interval: 10,
|
|
});
|
|
return response;
|
|
}
|
|
|
|
function invalidPdfPart() {
|
|
return {
|
|
name: "file",
|
|
filename: "not-really.pdf",
|
|
contentType: "application/pdf",
|
|
content: INVALID_PDF,
|
|
};
|
|
}
|
|
|
|
function validPdfPart() {
|
|
return {
|
|
name: "file",
|
|
filename: "scan.pdf",
|
|
contentType: "application/pdf",
|
|
content: VALID_PDF,
|
|
};
|
|
}
|
|
|
|
interface FlowNode {
|
|
data?: { inputRefs?: string[] };
|
|
children?: FlowNode[];
|
|
}
|
|
|
|
function firstInputRef(node: FlowNode): string | undefined {
|
|
if (node.data?.inputRefs?.[0]) return node.data.inputRefs[0];
|
|
for (const child of node.children ?? []) {
|
|
const ref = firstInputRef(child);
|
|
if (ref) return ref;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
async function expectObjectDeleted(key: string) {
|
|
await vi.waitFor(async () => expect(await objectExists(key)).toBe(false), {
|
|
timeout: 1_000,
|
|
interval: 10,
|
|
});
|
|
}
|
|
|
|
describe("OCR PDF path-backed batch and pipeline ingress", () => {
|
|
it("rejects direct and tool-batch PDFs while their streams cross the configured cap", async () => {
|
|
const originalLimit = env.MAX_UPLOAD_SIZE_MB;
|
|
env.MAX_UPLOAD_SIZE_MB = 1 / (1024 * 1024);
|
|
try {
|
|
const oversized = {
|
|
name: "file",
|
|
filename: "oversized.pdf",
|
|
contentType: "application/pdf",
|
|
content: Buffer.from("12"),
|
|
};
|
|
const settings = {
|
|
name: "settings",
|
|
content: JSON.stringify({ quality: "fast", pages: "1" }),
|
|
};
|
|
|
|
const direct = await postMultipart("/api/v1/tools/pdf/ocr-pdf", [oversized, settings]);
|
|
const batch = await postMultipart("/api/v1/tools/pdf/ocr-pdf/batch", [oversized, settings]);
|
|
|
|
expect(direct.statusCode).toBe(413);
|
|
expect(batch.statusCode).toBe(413);
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
env.MAX_UPLOAD_SIZE_MB = originalLimit;
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
"file-first",
|
|
"field-first",
|
|
] as const)("rejects oversized pipeline OCR PDFs with %s multipart ordering", async (ordering) => {
|
|
const originalLimit = env.MAX_UPLOAD_SIZE_MB;
|
|
env.MAX_UPLOAD_SIZE_MB = 1 / (1024 * 1024);
|
|
try {
|
|
const oversized = {
|
|
name: "file",
|
|
filename: "oversized.pdf",
|
|
contentType: "application/pdf",
|
|
content: Buffer.from("12"),
|
|
};
|
|
const pipelinePart = { name: "pipeline", content: OCR_PIPELINE };
|
|
const parts =
|
|
ordering === "file-first" ? [oversized, pipelinePart] : [pipelinePart, oversized];
|
|
|
|
const execute = await postMultipart("/api/v1/pipeline/execute", parts);
|
|
const batch = await postMultipart("/api/v1/pipeline/batch", parts);
|
|
|
|
expect(execute.statusCode).toBe(413);
|
|
expect(batch.statusCode).toBe(413);
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
env.MAX_UPLOAD_SIZE_MB = originalLimit;
|
|
}
|
|
});
|
|
|
|
it("enforces the OCR stream cap before a trailing pipeline field reveals the modality", async () => {
|
|
const originalLimit = env.MAX_UPLOAD_SIZE_MB;
|
|
env.MAX_UPLOAD_SIZE_MB = 0;
|
|
mocks.ocrUploadLimitsOverride = { fileBytes: 1, aggregateBytes: 1 };
|
|
try {
|
|
const oversized = {
|
|
name: "file",
|
|
filename: "oversized.pdf",
|
|
contentType: "application/pdf",
|
|
content: Buffer.from("12"),
|
|
};
|
|
const pipelinePart = { name: "pipeline", content: OCR_PIPELINE };
|
|
|
|
const execute = await postMultipart("/api/v1/pipeline/execute", [oversized, pipelinePart]);
|
|
const batch = await postMultipart("/api/v1/pipeline/batch", [oversized, pipelinePart]);
|
|
|
|
expect(execute.statusCode).toBe(413);
|
|
expect(batch.statusCode).toBe(413);
|
|
expect(mocks.flowAdd).not.toHaveBeenCalled();
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
} finally {
|
|
mocks.ocrUploadLimitsOverride = null;
|
|
env.MAX_UPLOAD_SIZE_MB = originalLimit;
|
|
}
|
|
});
|
|
|
|
it("validates tool-batch PDF files by path without invoking the buffered document handler", async () => {
|
|
const response = await postMultipart("/api/v1/tools/pdf/ocr-pdf/batch", [
|
|
invalidPdfPart(),
|
|
{ name: "settings", content: JSON.stringify({ quality: "fast", pages: "1" }) },
|
|
]);
|
|
|
|
expect(response.statusCode).toBe(422);
|
|
expect(JSON.parse(response.body).errors[0].error).toMatch(/PDF header/i);
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
expect(mocks.flowAdd).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects password-protected PDFs before enqueue across every OCR ingress surface", async () => {
|
|
const encrypted = {
|
|
name: "file",
|
|
filename: "encrypted.pdf",
|
|
contentType: "application/pdf",
|
|
content: ENCRYPTED_PDF,
|
|
};
|
|
const settings = {
|
|
name: "settings",
|
|
content: JSON.stringify({ quality: "fast", pages: "1" }),
|
|
};
|
|
const pipeline = { name: "pipeline", content: OCR_PIPELINE };
|
|
|
|
const direct = await postMultipart("/api/v1/tools/pdf/ocr-pdf", [encrypted, settings]);
|
|
const toolBatch = await postMultipart("/api/v1/tools/pdf/ocr-pdf/batch", [encrypted, settings]);
|
|
const execute = await postMultipart("/api/v1/pipeline/execute", [encrypted, pipeline]);
|
|
const pipelineBatch = await postMultipart("/api/v1/pipeline/batch", [encrypted, pipeline]);
|
|
|
|
expect(direct.statusCode).toBe(400);
|
|
expect(JSON.parse(direct.body).error).toMatch(/password-protected/i);
|
|
expect(toolBatch.statusCode).toBe(422);
|
|
expect(JSON.parse(toolBatch.body).errors[0].error).toMatch(/password-protected/i);
|
|
expect(execute.statusCode).toBe(400);
|
|
expect(JSON.parse(execute.body).error).toMatch(/password-protected/i);
|
|
expect(pipelineBatch.statusCode).toBe(422);
|
|
expect(JSON.parse(pipelineBatch.body).errors[0].error).toMatch(/password-protected/i);
|
|
expect(mocks.flowAdd).not.toHaveBeenCalled();
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"file-first",
|
|
"field-first",
|
|
] as const)("validates execute-pipeline OCR PDF input by path with %s multipart ordering", async (ordering) => {
|
|
const pipelinePart = { name: "pipeline", content: OCR_PIPELINE };
|
|
const parts =
|
|
ordering === "file-first"
|
|
? [invalidPdfPart(), pipelinePart]
|
|
: [pipelinePart, invalidPdfPart()];
|
|
|
|
const response = await postMultipart("/api/v1/pipeline/execute", parts);
|
|
|
|
expect(response.statusCode).toBe(400);
|
|
expect(JSON.parse(response.body).error).toMatch(/PDF header/i);
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
"file-first",
|
|
"field-first",
|
|
] as const)("validates batch-pipeline OCR PDF files by path with %s multipart ordering", async (ordering) => {
|
|
const pipelinePart = { name: "pipeline", content: OCR_PIPELINE };
|
|
const parts =
|
|
ordering === "file-first"
|
|
? [invalidPdfPart(), pipelinePart]
|
|
: [pipelinePart, invalidPdfPart()];
|
|
|
|
const response = await postMultipart("/api/v1/pipeline/batch", parts);
|
|
|
|
expect(response.statusCode).toBe(422);
|
|
expect(JSON.parse(response.body).errors[0].error).toMatch(/PDF header/i);
|
|
expect(prepareSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rolls back tool-batch OCR objects when BullMQ rejects the flow handoff", async () => {
|
|
mocks.flowAdd.mockRejectedValueOnce(new Error("Redis unavailable"));
|
|
const parentId = "ocr-pdf-tool-batch-rollback";
|
|
const response = await postMultipart("/api/v1/tools/pdf/ocr-pdf/batch", [
|
|
validPdfPart(),
|
|
{ name: "settings", content: JSON.stringify({ quality: "fast", pages: "1" }) },
|
|
{ name: "clientJobId", content: parentId },
|
|
]);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
await expectObjectDeleted(`uploads/${parentId}-f0/scan.pdf`);
|
|
});
|
|
|
|
it("rolls back execute-pipeline OCR objects when BullMQ rejects the flow handoff", async () => {
|
|
mocks.flowAdd.mockRejectedValueOnce(new Error("Redis unavailable"));
|
|
const response = await postMultipart("/api/v1/pipeline/execute", [
|
|
validPdfPart(),
|
|
{ name: "pipeline", content: OCR_PIPELINE },
|
|
]);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
const tree = mocks.flowAdd.mock.calls[0]?.[0] as FlowNode | undefined;
|
|
const key = tree ? firstInputRef(tree) : undefined;
|
|
expect(key).toMatch(/^uploads\/[0-9a-f-]+\/scan\.pdf$/i);
|
|
await expectObjectDeleted(key as string);
|
|
});
|
|
|
|
it("rolls back batch-pipeline OCR objects when BullMQ rejects the flow handoff", async () => {
|
|
mocks.flowAdd.mockRejectedValueOnce(new Error("Redis unavailable"));
|
|
const parentId = "ocr-pdf-pipeline-batch-rollback";
|
|
const response = await postMultipart("/api/v1/pipeline/batch", [
|
|
validPdfPart(),
|
|
{ name: "pipeline", content: OCR_PIPELINE },
|
|
{ name: "clientJobId", content: parentId },
|
|
]);
|
|
|
|
expect(response.statusCode).toBe(500);
|
|
await expectObjectDeleted(`uploads/${parentId}-f0-s0/scan.pdf`);
|
|
});
|
|
});
|