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
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|