import assert from 'node:assert/strict' import { afterEach, beforeEach, test } from 'node:test' import listPdfs, { downloadPdf } from '../src/attachments.js' import { BEARER, HEY, STORAGE, installFetch, jsonResponse, redirectResponse, resetKodySecrets, } from './helpers.mjs' const PDF_BYTES = new Uint8Array([0x25, 0x50, 0x44, 0x46]) const PDF_BASE64 = btoa(String.fromCharCode(...PDF_BYTES)) const MAX_RAW_BYTES = 24 * 1024 * 1024 const OVER_RAW = MAX_RAW_BYTES + 1 const TWO_PDFS = '
' + '
' const UNDERSTATED = '
' const RAILS_ONLY = '/rails/active_storage/blobs/redirect/stub/rails.pdf' const DUP_PDFS = '
' + '
' const OVER_CAP = `
` const EXACT_CAP = `
` const SMALL_PDF_URL = `${HEY}/rails/active_storage/blobs/redirect/stub/small.pdf` const BIG_PDF_URL = `${HEY}/rails/active_storage/blobs/redirect/stub/big.pdf` const HUGE_PDF_URL = `${HEY}/rails/active_storage/blobs/redirect/stub/huge.pdf` const RAILS_PDF_URL = `${HEY}/rails/active_storage/blobs/redirect/stub/rails.pdf` const INVOICE_A_URL = `${HEY}/rails/active_storage/blobs/redirect/stub/a/invoice.pdf` let fixture beforeEach(() => { resetKodySecrets() fixture = installFetch(async (url, init) => { const method = String(init.method || 'GET').toUpperCase() const handler = fixture.routes.get(`${method} ${url}`) if (handler) return handler(url, init) throw new Error(`unexpected ${method} ${url}`) }) fixture.routes = new Map() }) afterEach(() => { fixture?.restore() }) function on(method, url, handler) { fixture.routes.set(`${method} ${url}`, handler) } function stubPdf(heyUrl, body = PDF_BYTES, extraHeaders = {}) { const storageUrl = `${STORAGE}${new URL(heyUrl).pathname}` on('GET', heyUrl, () => redirectResponse(302, storageUrl)) on('GET', storageUrl, () => new Response(body, { status: 200, headers: { 'content-type': 'application/pdf', ...extraHeaders } }), ) return storageUrl } function stubSmallPdf() { stubPdf(SMALL_PDF_URL) } function countingStream(chunkBytes = 65536) { const state = { pulled: 0 } const hardStop = MAX_RAW_BYTES + 1024 * 1024 const stream = new ReadableStream({ pull(controller) { state.pulled += chunkBytes if (state.pulled > hardStop) { controller.error(new Error('read past the 24 MiB cap')) return } controller.enqueue(new Uint8Array(chunkBytes)) }, }) return { stream, state } } function stubOversizePdf(heyUrl, { contentLength, chunkBytes = 65536 } = {}) { const storageUrl = `${STORAGE}${new URL(heyUrl).pathname}` const { stream, state } = countingStream(chunkBytes) const headers = { 'content-type': 'application/pdf' } if (contentLength != null) headers['content-length'] = String(contentLength) on('GET', heyUrl, () => redirectResponse(302, storageUrl)) on('GET', storageUrl, () => new Response(stream, { status: 200, headers })) return state } function envelopeSize({ filename, contentType, url, filesize, base64 }) { return JSON.stringify({ filename, contentType, url, filesize, base64 }).length } function smallEnvelope() { return envelopeSize({ filename: 'small.pdf', contentType: 'application/pdf', url: SMALL_PDF_URL, filesize: PDF_BYTES.byteLength, base64: PDF_BASE64, }) } function railsEnvelope() { return envelopeSize({ filename: 'rails.pdf', contentType: 'application/pdf', url: RAILS_PDF_URL, filesize: PDF_BYTES.byteLength, base64: PDF_BASE64, }) } function assertMetadataOnly(item) { assert.equal('bytes' in item, false) assert.equal('base64' in item, false) assert.ok(!item.url.startsWith(STORAGE)) assert.equal(new URL(item.url).origin, HEY) } function assertNoPdfDownload() { assert.equal( fixture.requests.some((row) => row.url.includes('/rails/active_storage/') || row.url.startsWith(STORAGE)), false, ) } test('listPdfs returns metadata only and does not download bodies', async () => { on('GET', `${HEY}/messages/8001.json`, () => jsonResponse(200, { id: 8001, content: TWO_PDFS }), ) const pdfs = await listPdfs({ messageId: 8001 }) assert.equal(pdfs.length, 2) assert.deepEqual(pdfs, [ { index: 0, filename: 'big.pdf', contentType: 'application/pdf', url: BIG_PDF_URL, filesize: 6970000, }, { index: 1, filename: 'small.pdf', contentType: 'application/pdf', url: SMALL_PDF_URL, filesize: 4, }, ]) pdfs.forEach(assertMetadataOnly) assert.deepEqual( fixture.requests.map((row) => row.url), [`${HEY}/messages/8001.json`], ) assert.equal(fixture.requests[0].headers.authorization, BEARER) }) test('downloadPdf returns one file as base64 by filename', async () => { stubSmallPdf() const pdf = await downloadPdf({ content: TWO_PDFS, filename: 'small.pdf', maxEncodedBytes: 1024, }) assert.equal(typeof pdf.base64, 'string') assert.equal(pdf.base64, PDF_BASE64) assert.equal(pdf.filename, 'small.pdf') assert.equal(pdf.contentType, 'application/pdf') assert.equal(pdf.filesize, PDF_BYTES.byteLength) assert.equal(pdf.url, SMALL_PDF_URL) assert.equal('bytes' in pdf, false) assert.equal(pdf.base64 instanceof Uint8Array, false) assert.ok(!pdf.url.startsWith(STORAGE)) assert.equal( fixture.requests.some((row) => row.url === BIG_PDF_URL || row.url.includes('big.pdf')), false, ) }) test('downloadPdf returns one file as base64 by index', async () => { stubSmallPdf() const pdf = await downloadPdf({ content: TWO_PDFS, index: 1, maxEncodedBytes: 1024, }) assert.equal(pdf.filename, 'small.pdf') assert.equal(pdf.base64, PDF_BASE64) assert.equal(typeof pdf.base64, 'string') assert.equal('bytes' in pdf, false) assert.equal(pdf.url, SMALL_PDF_URL) }) test('downloadPdf refuses over budget, names the full envelope size, and does not return a truncated body', async () => { const required = smallEnvelope() const budget = required - 1 await assert.rejects( () => downloadPdf({ content: TWO_PDFS, filename: 'small.pdf', maxEncodedBytes: budget, }), (error) => { assert.match(error.message, new RegExp(`encoded size is ${required}`)) assert.match(error.message, new RegExp(`Raise responseLimit to at least ${required}`)) assert.match(error.message, /small\.pdf/) assert.notEqual(required, PDF_BASE64.length) return true }, ) assertNoPdfDownload() }) test('downloadPdf hard-refuses when declared raw size is over 24 MiB', async () => { await assert.rejects( () => downloadPdf({ content: OVER_CAP, filename: 'huge.pdf', maxEncodedBytes: 40 * 1024 * 1024, }), (error) => { assert.match(error.message, new RegExp(`raw size is ${OVER_RAW}`)) assert.match(error.message, /24 MiB/) assert.match(error.message, new RegExp(String(MAX_RAW_BYTES))) assert.match(error.message, /huge\.pdf/) return true }, ) assertNoPdfDownload() }) test('downloadPdf envelope size equal to maxEncodedBytes succeeds, one byte over refuses', async () => { stubSmallPdf() const required = smallEnvelope() const pdf = await downloadPdf({ content: TWO_PDFS, filename: 'small.pdf', maxEncodedBytes: required, }) assert.equal(JSON.stringify(pdf).length, required) assert.equal(pdf.base64, PDF_BASE64) fixture.requests.length = 0 stubSmallPdf() await assert.rejects( () => downloadPdf({ content: TWO_PDFS, filename: 'small.pdf', maxEncodedBytes: required - 1, }), (error) => { assert.match(error.message, new RegExp(`Raise responseLimit to at least ${required}`)) return true }, ) }) test('downloadPdf allows a declared raw size of exactly 24 MiB and refuses one byte over', async () => { await assert.rejects( () => downloadPdf({ content: EXACT_CAP, filename: 'exact.pdf', maxEncodedBytes: 1, }), (error) => { assert.match(error.message, /encoded size is/) assert.match(error.message, /Raise responseLimit/) assert.doesNotMatch(error.message, /24 MiB/) return true }, ) assertNoPdfDownload() await assert.rejects( () => downloadPdf({ content: OVER_CAP, filename: 'huge.pdf', maxEncodedBytes: 40 * 1024 * 1024, }), /24 MiB/, ) }) test('downloadPdf refuses after download when filesize is missing, for budget and 24 MiB', async () => { stubPdf(RAILS_PDF_URL) const required = railsEnvelope() await assert.rejects( () => downloadPdf({ content: RAILS_ONLY, filename: 'rails.pdf', maxEncodedBytes: required - 1, }), (error) => { assert.match(error.message, new RegExp(`encoded size is ${required}`)) assert.match(error.message, new RegExp(`Raise responseLimit to at least ${required}`)) return true }, ) assert.equal(fixture.requests.some((row) => row.url === RAILS_PDF_URL), true) fixture.requests.length = 0 const overState = stubOversizePdf(RAILS_PDF_URL) await assert.rejects( () => downloadPdf({ content: RAILS_ONLY, filename: 'rails.pdf', maxEncodedBytes: 40 * 1024 * 1024, }), (error) => { assert.match(error.message, /24 MiB/) const n = Number(error.message.match(/raw size is (\d+)/)?.[1]) assert.ok(n > MAX_RAW_BYTES) return true }, ) assert.ok(overState.pulled > 0) assert.ok(overState.pulled <= MAX_RAW_BYTES + 1024 * 1024) }) test('downloadPdf refuses after download when filesize is understated, for budget and 24 MiB', async () => { stubSmallPdf() const required = smallEnvelope() await assert.rejects( () => downloadPdf({ content: UNDERSTATED, filename: 'small.pdf', maxEncodedBytes: required - 1, }), (error) => { assert.match(error.message, new RegExp(`encoded size is ${required}`)) assert.match(error.message, new RegExp(`Raise responseLimit to at least ${required}`)) return true }, ) assert.equal(fixture.requests.some((row) => row.url === SMALL_PDF_URL), true) fixture.requests.length = 0 const overState = stubOversizePdf(SMALL_PDF_URL, { contentLength: OVER_RAW }) await assert.rejects( () => downloadPdf({ content: UNDERSTATED, filename: 'small.pdf', maxEncodedBytes: 40 * 1024 * 1024, }), (error) => { assert.match(error.message, new RegExp(`raw size is ${OVER_RAW}`)) assert.match(error.message, /24 MiB/) return true }, ) assert.ok(overState.pulled === 0 || overState.pulled <= MAX_RAW_BYTES + 65536) }) test('downloadPdf validates filename or index before any HEY request', async () => { on('GET', `${HEY}/messages/1.json`, () => { throw new Error('GET /messages should not run') }) await assert.rejects( () => downloadPdf({ messageId: 1, maxEncodedBytes: 10 }), /filename or index is required/, ) assert.equal(fixture.requests.length, 0) await assert.rejects( () => downloadPdf({ messageId: 1, index: '0', maxEncodedBytes: 10 }), /index must be a non-negative integer/, ) await assert.rejects( () => downloadPdf({ messageId: 1, index: 1.5, maxEncodedBytes: 10 }), /index must be a non-negative integer/, ) assert.equal(fixture.requests.length, 0) }) test('downloadPdf throws when two PDFs share a filename and tells the caller to use index', async () => { stubPdf(INVOICE_A_URL) await assert.rejects( () => downloadPdf({ content: DUP_PDFS, filename: 'invoice.pdf', maxEncodedBytes: 1024, }), (error) => { assert.match(error.message, /multiple PDFs named invoice\.pdf/) assert.match(error.message, /pass index/) return true }, ) assertNoPdfDownload() const pdf = await downloadPdf({ content: DUP_PDFS, index: 0, maxEncodedBytes: 1024, }) assert.equal(pdf.filename, 'invoice.pdf') assert.equal(pdf.url, INVOICE_A_URL) assert.equal(pdf.base64, PDF_BASE64) }) test('attachments default export is listPdfs and listAndDownloadPdfs is absent', async () => { const mod = await import('../src/attachments.js') assert.equal(mod.default, mod.listPdfs) assert.equal('listAndDownloadPdfs' in mod, false) assert.equal(typeof mod.listPdfs, 'function') assert.equal(typeof mod.downloadPdf, 'function') assert.deepEqual(Object.keys(mod).sort(), [ 'default', 'downloadPdf', 'getMessage', 'listPdfs', 'parseActionTextAttachments', 'parseRailsPdfUrls', 'parseTrixAttachments', ]) })