Skip to content
← Public packages

@danj/hey-email

HEY.com email for Kody: search threads, read messages, download PDF attachments, and write drafts. Sending is barred until you allow it.

test/attachments.test.js

432 lines · 13.9 KB · JavaScript
Raw
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 =
  '<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/big.pdf&quot;,&quot;filename&quot;:&quot;big.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:6970000}"></figure>' +
  '<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/small.pdf&quot;,&quot;filename&quot;:&quot;small.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:4}"></figure>'

const UNDERSTATED =
  '<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/small.pdf&quot;,&quot;filename&quot;:&quot;small.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:1}"></figure>'

const RAILS_ONLY = '/rails/active_storage/blobs/redirect/stub/rails.pdf'

const DUP_PDFS =
  '<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/a/invoice.pdf&quot;,&quot;filename&quot;:&quot;invoice.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:4}"></figure>' +
  '<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/b/invoice.pdf&quot;,&quot;filename&quot;:&quot;invoice.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:4}"></figure>'

const OVER_CAP =
  `<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/huge.pdf&quot;,&quot;filename&quot;:&quot;huge.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:${OVER_RAW}}"></figure>`

const EXACT_CAP =
  `<figure data-trix-attachment="{&quot;url&quot;:&quot;/rails/active_storage/blobs/redirect/stub/exact.pdf&quot;,&quot;filename&quot;:&quot;exact.pdf&quot;,&quot;contentType&quot;:&quot;application/pdf&quot;,&quot;filesize&quot;:${MAX_RAW_BYTES}}"></figure>`

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',
  ])
})