diff --git a/src/js/logic/decrypt-pdf-page.ts b/src/js/logic/decrypt-pdf-page.ts index 4e93018..58f274b 100644 --- a/src/js/logic/decrypt-pdf-page.ts +++ b/src/js/logic/decrypt-pdf-page.ts @@ -2,9 +2,9 @@ import { showAlert } from '../ui.js'; import { downloadFile, formatBytes, - initializeQpdf, readFileAsArrayBuffer, } from '../utils/helpers.js'; +import { decryptPdfBytes } from '../utils/pdf-decrypt.js'; import { icons, createIcons } from 'lucide'; import JSZip from 'jszip'; import { DecryptPdfState } from '@/types'; @@ -115,79 +115,39 @@ async function decryptPdf() { const loaderModal = document.getElementById('loader-modal'); const loaderText = document.getElementById('loader-text'); - let qpdf: any; try { if (loaderModal) loaderModal.classList.remove('hidden'); if (loaderText) loaderText.textContent = 'Initializing decryption...'; - qpdf = await initializeQpdf(); - if (pageState.files.length === 1) { // Single file: decrypt and download directly const file = pageState.files[0]; - const inputPath = '/input.pdf'; - const outputPath = '/output.pdf'; + if (loaderText) loaderText.textContent = 'Reading encrypted PDF...'; + const fileBuffer = await readFileAsArrayBuffer(file); + const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer); - try { - if (loaderText) loaderText.textContent = 'Reading encrypted PDF...'; - const fileBuffer = await readFileAsArrayBuffer(file); - const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer); - qpdf.FS.writeFile(inputPath, uint8Array); + if (loaderText) loaderText.textContent = 'Decrypting PDF...'; + const { bytes: decryptedBytes } = await decryptPdfBytes( + uint8Array, + password + ); - if (loaderText) loaderText.textContent = 'Decrypting PDF...'; - const args = [ - inputPath, - '--password=' + password, - '--decrypt', - outputPath, - ]; + if (loaderText) loaderText.textContent = 'Preparing download...'; + const blob = new Blob([decryptedBytes.slice().buffer], { + type: 'application/pdf', + }); + downloadFile(blob, `unlocked-${file.name}`); - try { - qpdf.callMain(args); - } catch (qpdfError: any) { - if ( - qpdfError.message?.includes('invalid password') || - qpdfError.message?.includes('password') - ) { - throw new Error('INVALID_PASSWORD'); - } - throw qpdfError; + if (loaderModal) loaderModal.classList.add('hidden'); + showAlert( + 'Success', + 'PDF decrypted successfully! Your download has started.', + 'success', + () => { + resetState(); } - - if (loaderText) loaderText.textContent = 'Preparing download...'; - const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' }); - - if (outputFile.length === 0) { - throw new Error('Decryption resulted in an empty file.'); - } - - const blob = new Blob([new Uint8Array(outputFile)], { - type: 'application/pdf', - }); - downloadFile(blob, `unlocked-${file.name}`); - - if (loaderModal) loaderModal.classList.add('hidden'); - showAlert( - 'Success', - 'PDF decrypted successfully! Your download has started.', - 'success', - () => { - resetState(); - } - ); - } finally { - try { - if (qpdf?.FS) { - if (qpdf.FS.analyzePath(inputPath).exists) - qpdf.FS.unlink(inputPath); - if (qpdf.FS.analyzePath(outputPath).exists) - qpdf.FS.unlink(outputPath); - } - } catch (cleanupError) { - console.warn('Failed to cleanup WASM FS:', cleanupError); - } - } + ); } else { // Multiple files: decrypt all and download as ZIP const zip = new JSZip(); @@ -196,8 +156,6 @@ async function decryptPdf() { for (let i = 0; i < pageState.files.length; i++) { const file = pageState.files[i]; - const inputPath = `/input_${i}.pdf`; - const outputPath = `/output_${i}.pdf`; if (loaderText) loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`; @@ -205,55 +163,16 @@ async function decryptPdf() { try { const fileBuffer = await readFileAsArrayBuffer(file); const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer); - qpdf.FS.writeFile(inputPath, uint8Array); + const { bytes: decryptedBytes } = await decryptPdfBytes( + uint8Array, + password + ); - const args = [ - inputPath, - '--password=' + password, - '--decrypt', - outputPath, - ]; - - try { - qpdf.callMain(args); - } catch (qpdfError: any) { - if ( - qpdfError.message?.includes('invalid password') || - qpdfError.message?.includes('password') - ) { - throw new Error(`Invalid password for ${file.name}`); - } - throw qpdfError; - } - - const outputFile = qpdf.FS.readFile(outputPath, { - encoding: 'binary', - }); - if (!outputFile || outputFile.length === 0) { - throw new Error( - `Decryption resulted in an empty file for ${file.name}.` - ); - } - - zip.file(`unlocked-${file.name}`, outputFile, { binary: true }); + zip.file(`unlocked-${file.name}`, decryptedBytes, { binary: true }); successCount++; } catch (fileError: any) { errorCount++; console.error(`Failed to decrypt ${file.name}:`, fileError); - } finally { - try { - if (qpdf?.FS) { - if (qpdf.FS.analyzePath(inputPath).exists) - qpdf.FS.unlink(inputPath); - if (qpdf.FS.analyzePath(outputPath).exists) - qpdf.FS.unlink(outputPath); - } - } catch (cleanupError) { - console.warn( - `Failed to cleanup WASM FS for ${file.name}:`, - cleanupError - ); - } } } diff --git a/src/js/utils/pdf-decrypt.ts b/src/js/utils/pdf-decrypt.ts new file mode 100644 index 0000000..44caf80 --- /dev/null +++ b/src/js/utils/pdf-decrypt.ts @@ -0,0 +1,172 @@ +import { getCpdf, isCpdfAvailable } from './cpdf-helper'; +import { isPyMuPDFAvailable, loadPyMuPDF } from './pymupdf-loader'; + +export type PdfDecryptEngine = 'cpdf' | 'pymupdf'; + +export interface PdfDecryptResult { + bytes: Uint8Array; + engine: PdfDecryptEngine; +} + +const DECRYPT_LOG_PREFIX = '[PDF Decrypt]'; + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + return String(error); +} + +function normalizeErrorMessage(error: unknown): string { + const message = getErrorMessage(error).trim(); + const cpdfReasonMatch = message.match(/Pdf\.PDFError\("([^"]+)"\)/); + if (cpdfReasonMatch?.[1]) { + return cpdfReasonMatch[1]; + } + + const trailingErrorMatch = message.match(/ERROR:\s*[^:]+:\s*(.+)$/); + if (trailingErrorMatch?.[1]) { + return trailingErrorMatch[1].trim(); + } + + return message; +} + +function copyBytes(bytes: Uint8Array): Uint8Array { + return Uint8Array.from(bytes); +} + +function cleanupCpdfDocument(cpdf: unknown, pdf: unknown): void { + if (!cpdf || !pdf || typeof cpdf !== 'object' || !('deletePdf' in cpdf)) { + return; + } + + try { + (cpdf as { deletePdf: (document: unknown) => void }).deletePdf(pdf); + } catch (cleanupError) { + console.warn( + `${DECRYPT_LOG_PREFIX} Failed to cleanup CoherentPDF document: ${normalizeErrorMessage(cleanupError)}` + ); + } +} + +async function decryptWithCpdf( + inputBytes: Uint8Array, + password: string +): Promise { + const cpdf = await getCpdf(); + cpdf.setSlow(); + + let pdf: unknown | null = null; + + try { + pdf = cpdf.fromMemory(new Uint8Array(inputBytes), password); + + if (cpdf.isEncrypted(pdf)) { + try { + cpdf.decryptPdf(pdf, password); + } catch { + cpdf.decryptPdfOwner(pdf, password); + } + } + + const outputBytes = cpdf.toMemory(pdf, false, false); + pdf = null; + + if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) { + throw new Error('CoherentPDF produced an empty decrypted file.'); + } + + return copyBytes(outputBytes); + } catch (error) { + if (pdf) { + cleanupCpdfDocument(cpdf, pdf); + } + + throw new Error(normalizeErrorMessage(error), { cause: error }); + } +} + +async function decryptWithPyMuPDF( + inputBytes: Uint8Array, + password: string +): Promise { + const pymupdf = await loadPyMuPDF(); + const document = await pymupdf.open( + new Blob([new Uint8Array(inputBytes)], { type: 'application/pdf' }) + ); + + try { + if (document.needsPass || document.isEncrypted) { + const authenticated = document.authenticate(password); + if (!authenticated) { + throw new Error('Invalid PDF password.'); + } + } + + const outputBytes = document.save(); + if (!(outputBytes instanceof Uint8Array) || outputBytes.length === 0) { + throw new Error('PyMuPDF produced an empty decrypted file.'); + } + + return copyBytes(outputBytes); + } finally { + document.close(); + } +} + +export async function decryptPdfBytes( + inputBytes: Uint8Array, + password: string +): Promise { + const errors: string[] = []; + + if (isCpdfAvailable()) { + console.info(`${DECRYPT_LOG_PREFIX} Trying CoherentPDF decryption`); + try { + const result: PdfDecryptResult = { + bytes: await decryptWithCpdf(inputBytes, password), + engine: 'cpdf', + }; + console.info( + `${DECRYPT_LOG_PREFIX} Decryption succeeded with CoherentPDF` + ); + return result; + } catch (error) { + const errorMessage = normalizeErrorMessage(error); + console.warn( + `${DECRYPT_LOG_PREFIX} Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: ${errorMessage}` + ); + errors.push(`CoherentPDF: ${errorMessage}`); + } + } else { + console.info( + `${DECRYPT_LOG_PREFIX} CoherentPDF is not configured, skipping to PyMuPDF` + ); + errors.push('CoherentPDF: not configured.'); + } + + if (isPyMuPDFAvailable()) { + console.info(`${DECRYPT_LOG_PREFIX} Trying PyMuPDF decryption`); + try { + const result: PdfDecryptResult = { + bytes: await decryptWithPyMuPDF(inputBytes, password), + engine: 'pymupdf', + }; + console.info(`${DECRYPT_LOG_PREFIX} Decryption succeeded with PyMuPDF`); + return result; + } catch (error) { + const errorMessage = normalizeErrorMessage(error); + console.warn( + `${DECRYPT_LOG_PREFIX} PyMuPDF decryption failed: ${errorMessage}` + ); + errors.push(`PyMuPDF: ${errorMessage}`); + } + } else { + console.warn(`${DECRYPT_LOG_PREFIX} PyMuPDF is not configured`); + errors.push('PyMuPDF: not configured.'); + } + + throw new Error(errors.join('\n')); +} diff --git a/src/js/workflow/nodes/decrypt-node.ts b/src/js/workflow/nodes/decrypt-node.ts index 6954151..06fb044 100644 --- a/src/js/workflow/nodes/decrypt-node.ts +++ b/src/js/workflow/nodes/decrypt-node.ts @@ -4,7 +4,7 @@ import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; import { PDFDocument } from 'pdf-lib'; -import { initializeQpdf } from '../../utils/helpers.js'; +import { decryptPdfBytes } from '../../utils/pdf-decrypt.js'; export class DecryptNode extends BaseWorkflowNode { readonly category = 'Secure PDF' as const; @@ -33,35 +33,13 @@ export class DecryptNode extends BaseWorkflowNode { return { pdf: await processBatch(pdfInputs, async (input) => { - const qpdf = await initializeQpdf(); - const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; - const inputPath = `/tmp/input_decrypt_${uid}.pdf`; - const outputPath = `/tmp/output_decrypt_${uid}.pdf`; - - let decryptedData: Uint8Array; - try { - qpdf.FS.writeFile(inputPath, input.bytes); - const args = password - ? [inputPath, '--password=' + password, '--decrypt', outputPath] - : [inputPath, '--decrypt', outputPath]; - qpdf.callMain(args); - - decryptedData = qpdf.FS.readFile(outputPath, { encoding: 'binary' }); - } finally { - try { - qpdf.FS.unlink(inputPath); - } catch { - /* cleanup */ - } - try { - qpdf.FS.unlink(outputPath); - } catch { - /* cleanup */ - } - } - - const resultBytes = new Uint8Array(decryptedData); - const document = await PDFDocument.load(resultBytes); + const { bytes: resultBytes } = await decryptPdfBytes( + input.bytes, + password + ); + const document = await PDFDocument.load(resultBytes, { + throwOnInvalidObject: false, + }); return { type: 'pdf', diff --git a/src/js/workflow/nodes/pdf-input-node.ts b/src/js/workflow/nodes/pdf-input-node.ts index d041479..4420347 100644 --- a/src/js/workflow/nodes/pdf-input-node.ts +++ b/src/js/workflow/nodes/pdf-input-node.ts @@ -3,7 +3,8 @@ import { BaseWorkflowNode } from './base-node'; import { pdfSocket } from '../sockets'; import type { PDFData, SocketData, MultiPDFData } from '../types'; import { PDFDocument } from 'pdf-lib'; -import { readFileAsArrayBuffer, initializeQpdf } from '../../utils/helpers.js'; +import { readFileAsArrayBuffer } from '../../utils/helpers.js'; +import { decryptPdfBytes } from '../../utils/pdf-decrypt.js'; export class EncryptedPDFError extends Error { constructor(public readonly filename: string) { @@ -63,44 +64,16 @@ export class PDFInputNode extends BaseWorkflowNode { async addDecryptedFile(file: File, password: string): Promise { const arrayBuffer = await readFileAsArrayBuffer(file); const bytes = new Uint8Array(arrayBuffer as ArrayBuffer); - const qpdf = await initializeQpdf(); - const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; - const inputPath = `/tmp/input_decrypt_${uid}.pdf`; - const outputPath = `/tmp/output_decrypt_${uid}.pdf`; - - try { - qpdf.FS.writeFile(inputPath, bytes); - qpdf.callMain([ - inputPath, - '--password=' + password, - '--decrypt', - outputPath, - ]); - const decryptedData = qpdf.FS.readFile(outputPath, { - encoding: 'binary', - }); - const decryptedBytes = new Uint8Array(decryptedData); - const document = await PDFDocument.load(decryptedBytes, { - throwOnInvalidObject: false, - }); - this.files.push({ - type: 'pdf', - document, - bytes: decryptedBytes, - filename: file.name, - }); - } finally { - try { - qpdf.FS.unlink(inputPath); - } catch { - /* cleanup */ - } - try { - qpdf.FS.unlink(outputPath); - } catch { - /* cleanup */ - } - } + const { bytes: decryptedBytes } = await decryptPdfBytes(bytes, password); + const document = await PDFDocument.load(decryptedBytes, { + throwOnInvalidObject: false, + }); + this.files.push({ + type: 'pdf', + document, + bytes: decryptedBytes, + filename: file.name, + }); } async setFile(file: File): Promise { diff --git a/src/tests/pdf-decrypt.test.ts b/src/tests/pdf-decrypt.test.ts new file mode 100644 index 0000000..7d2b6bd --- /dev/null +++ b/src/tests/pdf-decrypt.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockGetCpdf, + mockIsCpdfAvailable, + mockLoadPyMuPDF, + mockIsPyMuPDFAvailable, + mockPdfDocumentLoad, + mockReadFileAsArrayBuffer, +} = vi.hoisted(() => ({ + mockGetCpdf: vi.fn(), + mockIsCpdfAvailable: vi.fn(), + mockLoadPyMuPDF: vi.fn(), + mockIsPyMuPDFAvailable: vi.fn(), + mockPdfDocumentLoad: vi.fn(), + mockReadFileAsArrayBuffer: vi.fn(), +})); + +vi.mock('../js/utils/cpdf-helper', () => ({ + getCpdf: mockGetCpdf, + isCpdfAvailable: mockIsCpdfAvailable, +})); + +vi.mock('../js/utils/pymupdf-loader', () => ({ + loadPyMuPDF: mockLoadPyMuPDF, + isPyMuPDFAvailable: mockIsPyMuPDFAvailable, +})); + +vi.mock('pdf-lib', () => ({ + PDFDocument: { + load: mockPdfDocumentLoad, + }, +})); + +vi.mock('../js/utils/helpers.js', () => ({ + readFileAsArrayBuffer: mockReadFileAsArrayBuffer, +})); + +import * as pdfDecryptModule from '../js/utils/pdf-decrypt'; + +describe('pdf decrypt', () => { + let infoSpy: ReturnType; + let warnSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockIsCpdfAvailable.mockReturnValue(true); + mockIsPyMuPDFAvailable.mockReturnValue(true); + mockPdfDocumentLoad.mockResolvedValue({ loaded: true }); + mockReadFileAsArrayBuffer.mockResolvedValue( + new Uint8Array([1, 2, 3]).buffer + ); + }); + + it('uses CoherentPDF first when it succeeds', async () => { + const cpdf = { + setSlow: vi.fn(), + fromMemory: vi.fn().mockReturnValue({ pdf: true }), + isEncrypted: vi.fn().mockReturnValue(true), + decryptPdf: vi.fn(), + decryptPdfOwner: vi.fn(), + toMemory: vi.fn().mockReturnValue(new Uint8Array([4, 5, 6])), + deletePdf: vi.fn(), + }; + mockGetCpdf.mockResolvedValue(cpdf); + + const result = await pdfDecryptModule.decryptPdfBytes( + new Uint8Array([9]), + '1234' + ); + + expect(result).toEqual({ + bytes: new Uint8Array([4, 5, 6]), + engine: 'cpdf', + }); + expect(infoSpy).toHaveBeenCalledWith( + '[PDF Decrypt] Decryption succeeded with CoherentPDF' + ); + expect(cpdf.decryptPdf).toHaveBeenCalledWith({ pdf: true }, '1234'); + expect(mockLoadPyMuPDF).not.toHaveBeenCalled(); + }); + + it('falls back to PyMuPDF when CoherentPDF fails', async () => { + const cpdf = { + setSlow: vi.fn(), + fromMemory: vi.fn().mockImplementation(() => { + throw new Error('cpdf failed'); + }), + isEncrypted: vi.fn(), + decryptPdf: vi.fn(), + decryptPdfOwner: vi.fn(), + toMemory: vi.fn(), + deletePdf: vi.fn(), + }; + const pymupdfDocument = { + needsPass: true, + isEncrypted: true, + authenticate: vi.fn().mockReturnValue(true), + save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + close: vi.fn(), + }; + + mockGetCpdf.mockResolvedValue(cpdf); + mockLoadPyMuPDF.mockResolvedValue({ + open: vi.fn().mockResolvedValue(pymupdfDocument), + }); + + const result = await pdfDecryptModule.decryptPdfBytes( + new Uint8Array([9]), + '1234' + ); + + expect(result).toEqual({ + bytes: new Uint8Array([7, 8, 9]), + engine: 'pymupdf', + }); + expect(warnSpy).toHaveBeenCalledWith( + '[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: cpdf failed' + ); + expect(infoSpy).toHaveBeenCalledWith( + '[PDF Decrypt] Decryption succeeded with PyMuPDF' + ); + expect(pymupdfDocument.authenticate).toHaveBeenCalledWith('1234'); + expect(pymupdfDocument.close).toHaveBeenCalledTimes(1); + }); + + it('normalizes raw CoherentPDF error strings before fallback logging', async () => { + const cpdf = { + setSlow: vi.fn(), + fromMemory: vi.fn().mockImplementation(() => { + throw '0,248,Exports.CPDFError,32,ERROR: decryptPdfOwner: Pdf.PDFError("Bad or missing /P entry")'; + }), + isEncrypted: vi.fn(), + decryptPdf: vi.fn(), + decryptPdfOwner: vi.fn(), + toMemory: vi.fn(), + deletePdf: vi.fn(), + }; + const pymupdfDocument = { + needsPass: false, + isEncrypted: false, + authenticate: vi.fn(), + save: vi.fn().mockReturnValue(new Uint8Array([7, 8, 9])), + close: vi.fn(), + }; + + mockGetCpdf.mockResolvedValue(cpdf); + mockLoadPyMuPDF.mockResolvedValue({ + open: vi.fn().mockResolvedValue(pymupdfDocument), + }); + + const result = await pdfDecryptModule.decryptPdfBytes( + new Uint8Array([9]), + '1234' + ); + + expect(result.engine).toBe('pymupdf'); + expect(warnSpy).toHaveBeenCalledWith( + '[PDF Decrypt] Decryption with CoherentPDF failed. Falling back to PyMuPDF. Reason: Bad or missing /P entry' + ); + }); + + it('aggregates engine errors when decryption fails', async () => { + const cpdf = { + setSlow: vi.fn(), + fromMemory: vi.fn().mockImplementation(() => { + throw new Error('cpdf failed'); + }), + isEncrypted: vi.fn(), + decryptPdf: vi.fn(), + decryptPdfOwner: vi.fn(), + toMemory: vi.fn(), + deletePdf: vi.fn(), + }; + const pymupdfDocument = { + needsPass: true, + isEncrypted: true, + authenticate: vi.fn().mockReturnValue(false), + save: vi.fn(), + close: vi.fn(), + }; + + mockGetCpdf.mockResolvedValue(cpdf); + mockLoadPyMuPDF.mockResolvedValue({ + open: vi.fn().mockResolvedValue(pymupdfDocument), + }); + + await expect( + pdfDecryptModule.decryptPdfBytes(new Uint8Array([9]), '1234') + ).rejects.toThrow( + 'CoherentPDF: cpdf failed\nPyMuPDF: Invalid PDF password.' + ); + expect(warnSpy).toHaveBeenCalledWith( + '[PDF Decrypt] PyMuPDF decryption failed: Invalid PDF password.' + ); + expect(pymupdfDocument.close).toHaveBeenCalledTimes(1); + }); + + it('uses the shared decrypt helper in the workflow decrypt node', async () => { + const decryptSpy = vi + .spyOn(pdfDecryptModule, 'decryptPdfBytes') + .mockResolvedValue({ + bytes: new Uint8Array([8, 8, 8]), + engine: 'cpdf', + }); + + const { DecryptNode } = await import('../js/workflow/nodes/decrypt-node'); + + const node = new DecryptNode(); + const passwordControl = node.controls['password'] as { value?: string }; + passwordControl.value = 'secret'; + + const result = await node.data({ + pdf: [ + { + type: 'pdf', + document: {} as never, + bytes: new Uint8Array([1, 2, 3]), + filename: 'locked.pdf', + }, + ], + }); + + expect(decryptSpy).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]), + 'secret' + ); + expect(mockPdfDocumentLoad).toHaveBeenCalledWith( + new Uint8Array([8, 8, 8]), + { + throwOnInvalidObject: false, + } + ); + expect(result.pdf).toEqual({ + type: 'pdf', + document: { loaded: true }, + bytes: new Uint8Array([8, 8, 8]), + filename: 'locked_decrypted.pdf', + }); + }); + + it('uses the shared decrypt helper when adding a decrypted workflow input file', async () => { + const decryptSpy = vi + .spyOn(pdfDecryptModule, 'decryptPdfBytes') + .mockResolvedValue({ + bytes: new Uint8Array([6, 6, 6]), + engine: 'cpdf', + }); + + const { PDFInputNode } = + await import('../js/workflow/nodes/pdf-input-node'); + + const node = new PDFInputNode(); + const file = new File([new Uint8Array([1, 2, 3])], 'locked.pdf', { + type: 'application/pdf', + }); + + await node.addDecryptedFile(file, 'secret'); + + expect(mockReadFileAsArrayBuffer).toHaveBeenCalledWith(file); + expect(decryptSpy).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]), + 'secret' + ); + expect(mockPdfDocumentLoad).toHaveBeenCalledWith( + new Uint8Array([6, 6, 6]), + { + throwOnInvalidObject: false, + } + ); + expect(node.getFileCount()).toBe(1); + expect(node.getFilenames()).toEqual(['locked.pdf']); + }); +});