mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: make OCR portable and reliable across AMD64 and ARM64 (#519)
* 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
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { env } from "../../../apps/api/src/config.js";
|
||||
|
||||
const qpdf = vi.hoisted(() => ({
|
||||
available: vi.fn(),
|
||||
check: vi.fn(),
|
||||
pageCount: vi.fn(),
|
||||
requiresPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@snapotter/doc-engine", () => ({
|
||||
qpdfAvailable: qpdf.available,
|
||||
qpdfCheck: qpdf.check,
|
||||
qpdfPageCount: qpdf.pageCount,
|
||||
qpdfRequiresPassword: qpdf.requiresPassword,
|
||||
}));
|
||||
|
||||
import {
|
||||
DocumentInputHandler,
|
||||
validatePdfPath,
|
||||
} from "../../../apps/api/src/modality/document-input.js";
|
||||
|
||||
let scratchDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scratchDir = mkdtempSync(join(tmpdir(), "snapotter-document-input-"));
|
||||
qpdf.available.mockReturnValue(true);
|
||||
qpdf.requiresPassword.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scratchDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("DocumentInputHandler password policy", () => {
|
||||
it("rejects an encrypted PDF when the consuming tool cannot accept a password", async () => {
|
||||
const handler = new DocumentInputHandler();
|
||||
|
||||
await expect(
|
||||
handler.prepare(Buffer.from("%PDF-encrypted"), "scan.pdf", {
|
||||
scratchDir,
|
||||
rejectPasswordProtected: true,
|
||||
}),
|
||||
).rejects.toThrow(/password-protected/i);
|
||||
expect(qpdf.check).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the existing policy for tools such as unlock-pdf", async () => {
|
||||
const handler = new DocumentInputHandler();
|
||||
const input = Buffer.from("%PDF-encrypted");
|
||||
|
||||
await expect(handler.prepare(input, "scan.pdf", { scratchDir })).resolves.toEqual({
|
||||
buffer: input,
|
||||
filename: "scan.pdf",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires PDF magic for a PDF-only consumer regardless of the client filename", async () => {
|
||||
const handler = new DocumentInputHandler();
|
||||
|
||||
await expect(
|
||||
handler.prepare(Buffer.from("not a PDF"), "renamed.txt", {
|
||||
scratchDir,
|
||||
rejectPasswordProtected: true,
|
||||
}),
|
||||
).rejects.toThrow(/PDF header/i);
|
||||
expect(qpdf.requiresPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("path-backed PDF validation", () => {
|
||||
it("runs structural, encryption, and page validation directly against the file path", async () => {
|
||||
const inputPath = join(scratchDir, "scan.pdf");
|
||||
writeFileSync(inputPath, "%PDF-path-backed");
|
||||
qpdf.requiresPassword.mockResolvedValueOnce(false);
|
||||
qpdf.pageCount.mockResolvedValueOnce(3);
|
||||
const originalMaxPages = env.MAX_PDF_PAGES;
|
||||
env.MAX_PDF_PAGES = 10;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
validatePdfPath(inputPath, { rejectPasswordProtected: true }),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
env.MAX_PDF_PAGES = originalMaxPages;
|
||||
}
|
||||
|
||||
expect(qpdf.requiresPassword).toHaveBeenCalledWith(inputPath);
|
||||
expect(qpdf.check).toHaveBeenCalledWith(inputPath);
|
||||
expect(qpdf.pageCount).toHaveBeenCalledWith(inputPath);
|
||||
});
|
||||
|
||||
it("enforces the PDF page cap without loading the file into a Buffer", async () => {
|
||||
const inputPath = join(scratchDir, "too-many-pages.pdf");
|
||||
writeFileSync(inputPath, "%PDF-path-backed");
|
||||
qpdf.requiresPassword.mockResolvedValueOnce(false);
|
||||
qpdf.pageCount.mockResolvedValueOnce(11);
|
||||
const originalMaxPages = env.MAX_PDF_PAGES;
|
||||
env.MAX_PDF_PAGES = 10;
|
||||
|
||||
try {
|
||||
await expect(validatePdfPath(inputPath, { rejectPasswordProtected: true })).rejects.toThrow(
|
||||
/11 pages.*maximum of 10/i,
|
||||
);
|
||||
} finally {
|
||||
env.MAX_PDF_PAGES = originalMaxPages;
|
||||
}
|
||||
});
|
||||
|
||||
it("reports qpdf structural failures as input validation errors", async () => {
|
||||
const inputPath = join(scratchDir, "damaged.pdf");
|
||||
writeFileSync(inputPath, "%PDF-path-backed");
|
||||
qpdf.requiresPassword.mockResolvedValueOnce(false);
|
||||
qpdf.check.mockRejectedValueOnce(new Error("xref table is corrupt"));
|
||||
|
||||
await expect(validatePdfPath(inputPath, { rejectPasswordProtected: true })).rejects.toThrow(
|
||||
/Damaged PDF.*xref table is corrupt/i,
|
||||
);
|
||||
expect(qpdf.pageCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops before invoking qpdf when path validation is canceled", async () => {
|
||||
const inputPath = join(scratchDir, "canceled.pdf");
|
||||
writeFileSync(inputPath, "%PDF-canceled");
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
validatePdfPath(inputPath, {
|
||||
rejectPasswordProtected: true,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(qpdf.requiresPassword).not.toHaveBeenCalled();
|
||||
expect(qpdf.check).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user