mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

body.test.ts

Language TypeScript18.5 KB462 lines

packages/gmail-client/test/body.test.ts

  1. /**
  2. * Reading a message body.
  3. *
  4. * The risks of reading full bodies, and what is tested here: sender-controlled markup reaching the page, a body that
  5. * decodes to mojibake, and a walk that returns an attachment instead of the
  6. * message.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import { SA } from './sa.js'
  10. import {
  11. ATTACHMENT_DOWNLOAD_CAP,
  12. collectAttachments,
  13. decodeB64Url,
  14. decodeB64UrlBytes,
  15. extractBody,
  16. fetchAttachment,
  17. fetchBody,
  18. htmlToText,
  19. splitAddressList,
  20. } from '../src/index.js'
  21. const b64 = (s: string) =>
  22. btoa(String.fromCharCode(...new TextEncoder().encode(s))).replace(/\+/g, '-').replace(/\//g, '_')
  23. describe('decodeB64Url', () => {
  24. it('accepts Gmail’s URL-safe alphabet, which atob alone rejects', () => {
  25. expect(decodeB64Url(b64('a?b>c'))).toBe('a?b>c')
  26. })
  27. it('decodes UTF-8 rather than latin-1', () => {
  28. // atob gives bytes. Skipping the TextDecoder turns "café – €5" into mojibake
  29. // in every message a European vendor sends.
  30. expect(decodeB64Url(b64('café – €5 ✅'))).toBe('café – €5 ✅')
  31. })
  32. it('tolerates missing padding', () => {
  33. expect(decodeB64Url(b64('abc').replace(/=+$/, ''))).toBe('abc')
  34. })
  35. })
  36. describe('decodeB64UrlBytes', () => {
  37. it('returns the bytes, including the ones no text codec survives', () => {
  38. // 0xFD 0xFE 0xFF is invalid UTF-8. Through decodeB64Url each becomes
  39. // U+FFFD, which is a PDF the recipient cannot open, which is the whole reason
  40. // there are two decoders.
  41. const bytes = new Uint8Array([0, 1, 2, 253, 254, 255])
  42. let bin = ''
  43. for (const b of bytes) bin += String.fromCharCode(b)
  44. const b64url = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  45. expect([...decodeB64UrlBytes(b64url)]).toEqual([...bytes])
  46. })
  47. })
  48. describe('collectAttachments', () => {
  49. const part = (over: Record<string, unknown> = {}) => ({
  50. mimeType: 'application/pdf',
  51. filename: 'flyer.pdf',
  52. body: { attachmentId: 'a1', size: 2048 },
  53. ...over,
  54. })
  55. it('returns nothing for a message with no filenamed parts', () => {
  56. expect(collectAttachments({ mimeType: 'text/plain', body: { data: 'x' } })).toEqual([])
  57. })
  58. it('indexes in walk order, which is what the download route addresses', () => {
  59. const got = collectAttachments({
  60. mimeType: 'multipart/mixed',
  61. parts: [part(), part({ filename: 'b.pdf' }), { mimeType: 'text/plain', body: { data: 'x' } }],
  62. })
  63. expect(got.map((a) => [a.index, a.name])).toEqual([
  64. [0, 'flyer.pdf'],
  65. [1, 'b.pdf'],
  66. ])
  67. })
  68. it('reports an unmeasured size as null and never as 0', () => {
  69. const got = collectAttachments({ parts: [part({ body: { attachmentId: 'a1' } })] })
  70. expect(got[0]!.size).toBeNull()
  71. // A part Gmail says is genuinely empty is a real zero and stays one.
  72. const empty = collectAttachments({ parts: [part({ body: { attachmentId: 'a1', size: 0 } })] })
  73. expect(empty[0]!.size).toBe(0)
  74. })
  75. it('marks an inline image inline, by the same signals a mail client uses', () => {
  76. // A multi-party client thread can carry four of these: signature logos.
  77. // Counted as attachments they read as "4 attachments" on a message with none.
  78. const byDisposition = collectAttachments({
  79. parts: [
  80. part({
  81. filename: 'image001.jpg',
  82. mimeType: 'image/jpeg',
  83. headers: [{ name: 'Content-Disposition', value: 'inline; filename="image001.jpg"' }],
  84. }),
  85. ],
  86. })
  87. expect(byDisposition[0]!.inline).toBe(true)
  88. const byCid = collectAttachments({
  89. parts: [part({ filename: 'logo.png', headers: [{ name: 'Content-ID', value: '<logo>' }] })],
  90. })
  91. expect(byCid[0]!.inline).toBe(true)
  92. })
  93. it('does not call an attached file inline just because it has a disposition', () => {
  94. const got = collectAttachments({
  95. parts: [part({ headers: [{ name: 'Content-Disposition', value: 'attachment; filename="flyer.pdf"' }] })],
  96. })
  97. expect(got[0]!.inline).toBe(false)
  98. })
  99. it('records a part with no attachmentId as having none, rather than skipping it', () => {
  100. // Rule 9: a file that cannot be fetched is still a file that is there, and
  101. // the page says why there is no link.
  102. const got = collectAttachments({ parts: [part({ body: { size: 10 } })] })
  103. expect(got[0]!.attachmentId).toBeNull()
  104. })
  105. })
  106. describe('splitAddressList', () => {
  107. it('does not split inside a quoted display name', () => {
  108. // The client-thread case: `"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>`.
  109. // A split on ',' makes that two entries, one of which is an address that
  110. // would be sent to.
  111. expect(splitAddressList('"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>, sasha@lakeside-expo.example')).toEqual([
  112. '"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>',
  113. 'sasha@lakeside-expo.example',
  114. ])
  115. })
  116. it('does not split inside angle brackets, and drops empty entries', () => {
  117. expect(splitAddressList('a@b.com,, <c@d.com>')).toEqual(['a@b.com', '<c@d.com>'])
  118. expect(splitAddressList('')).toEqual([])
  119. expect(splitAddressList(' ')).toEqual([])
  120. })
  121. })
  122. describe('htmlToText', () => {
  123. it('drops the CONTENTS of style and script, not just their tags', () => {
  124. // Stripping tags alone leaves a wall of CSS in the middle of the message,
  125. // which is most of a marketing email by volume.
  126. const out = htmlToText('<style>.a{color:red}</style><p>Hello</p><script>alert(1)</script>')
  127. expect(out).toBe('Hello')
  128. })
  129. it('strips the zero-width filler marketing uses to pad preview text', () => {
  130. // Every promotional snippet in the live inbox is "͏ ͏ ͏ ͏" for hundreds of
  131. // characters. Left in, the message reads as a screen of blank lines.
  132. const padded = 'Real text' + '‌­​'.repeat(200)
  133. expect(htmlToText(padded)).toBe('Real text')
  134. })
  135. it('turns block ends into line breaks so paragraphs survive', () => {
  136. expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\ntwo')
  137. })
  138. it('unescapes entities', () => {
  139. expect(htmlToText('<p>Tom &amp; Jerry &lt;3 &quot;x&quot;</p>')).toBe('Tom & Jerry <3 "x"')
  140. })
  141. it('leaves no markup behind for the page to interpret', () => {
  142. const out = htmlToText('<img src=x onerror="alert(1)"><b>hi</b>')
  143. expect(out).not.toContain('<')
  144. expect(out).not.toContain('onerror')
  145. })
  146. })
  147. describe('extractBody', () => {
  148. const part = (mimeType: string, text: string, extra: Record<string, unknown> = {}) => ({
  149. mimeType,
  150. body: { data: b64(text) },
  151. ...extra,
  152. })
  153. it('prefers text/plain over the HTML alternative', () => {
  154. const out = extractBody({
  155. mimeType: 'multipart/alternative',
  156. parts: [part('text/plain', 'plain version'), part('text/html', '<p>html version</p>')],
  157. })
  158. // The raw HTML rides along even when plain wins the text field; the frame
  159. // route renders it.
  160. expect(out).toEqual({ text: 'plain version', kind: 'plain', html: '<p>html version</p>' })
  161. })
  162. it('falls back to HTML converted to text', () => {
  163. const out = extractBody({ mimeType: 'multipart/alternative', parts: [part('text/html', '<p>only html</p>')] })
  164. expect(out).toEqual({ text: 'only html', kind: 'html', html: '<p>only html</p>' })
  165. })
  166. it('digs through nested multiparts', () => {
  167. const out = extractBody({
  168. mimeType: 'multipart/mixed',
  169. parts: [
  170. { mimeType: 'multipart/alternative', parts: [part('text/plain', 'buried')] },
  171. part('application/pdf', 'not text', { filename: 'invoice.pdf' }),
  172. ],
  173. })
  174. expect(out.text).toBe('buried')
  175. })
  176. it('never returns an attachment as the body', () => {
  177. const out = extractBody({
  178. mimeType: 'multipart/mixed',
  179. parts: [part('text/plain', 'attached note', { filename: 'note.txt' })],
  180. })
  181. expect(out).toEqual({ text: '', kind: 'none', html: '' })
  182. })
  183. it('reports an image-only message as having no text, not as blank', () => {
  184. expect(extractBody({ mimeType: 'multipart/mixed', parts: [part('image/png', 'x')] }).kind).toBe('none')
  185. })
  186. })
  187. describe('fetchBody', () => {
  188. const CREDS = SA
  189. const json = (b: unknown, status = 200) =>
  190. new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })
  191. const message = {
  192. id: 'm1',
  193. internalDate: '1788210063000',
  194. payload: {
  195. mimeType: 'multipart/mixed',
  196. headers: [
  197. { name: 'From', value: 'Upscaler Labs <news@upscaler.vendor.example>' },
  198. { name: 'Subject', value: 'What is new this month' },
  199. { name: 'To', value: 'hello@meridiandisplay.example' },
  200. ],
  201. parts: [
  202. { mimeType: 'text/plain', body: { data: b64('Body text here.') } },
  203. { mimeType: 'application/pdf', filename: 'flyer.pdf', body: { attachmentId: 'a1' } },
  204. ],
  205. },
  206. }
  207. const ok = (async (url: string | URL) =>
  208. String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(message)) as typeof fetch
  209. it('asks for the full message, unlike the list', async () => {
  210. const seen: string[] = []
  211. const f = (async (url: string | URL) => {
  212. seen.push(String(url))
  213. return String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(message)
  214. }) as unknown as typeof fetch
  215. await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, f)
  216. expect(seen.find((u) => u.includes('/messages/m1'))).toContain('format=full')
  217. })
  218. it('returns the text, the headers, and each attachment with its handle', async () => {
  219. const r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, ok)
  220. expect('error' in r).toBe(false)
  221. if ('error' in r) return
  222. expect(r.text).toBe('Body text here.')
  223. expect(r.sender).toBe('news@upscaler.vendor.example')
  224. expect(r.to).toBe('hello@meridiandisplay.example')
  225. // Attachments carry an id and a size, not just a name: a name cannot be
  226. // fetched with and cannot be size-checked before fetching.
  227. expect(r.attachments).toEqual([
  228. {
  229. index: 0,
  230. name: 'flyer.pdf',
  231. mimeType: 'application/pdf',
  232. // The fixture part carries no `size`, so it is null and NOT 0; the
  233. // page prints "size not reported" rather than claiming an empty file.
  234. size: null,
  235. attachmentId: 'a1',
  236. inline: false,
  237. },
  238. ])
  239. })
  240. it('carries html:null for a plain-only message, and Gmail unread state', async () => {
  241. const r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, ok)
  242. if ('error' in r) throw new Error('expected a body')
  243. // No HTML part → null, not ''; the page decides frame-vs-text from this.
  244. expect(r.html).toBeNull()
  245. expect(r.htmlTruncated).toBe(false)
  246. expect(r.unread).toBe(false) // no labelIds in the fixture message
  247. })
  248. it('keeps the raw HTML for the frame route, capped and labelled when cut', async () => {
  249. // The spread in b64() blows the stack past ~100k chars; chunk it here.
  250. const bigRaw = '<p>' + 'x'.repeat(500_000) + '</p>'
  251. const bytes = new TextEncoder().encode(bigRaw)
  252. let bin = ''
  253. for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode(...bytes.subarray(i, i + 8192))
  254. const bigB64 = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  255. const htmlMsg = {
  256. id: 'm2',
  257. internalDate: '1788210063000',
  258. labelIds: ['UNREAD', 'INBOX'],
  259. payload: {
  260. mimeType: 'multipart/alternative',
  261. headers: [{ name: 'From', value: 'a@b.com' }],
  262. parts: [{ mimeType: 'text/html', body: { data: bigB64 } }],
  263. },
  264. }
  265. const f = (async (url: string | URL) =>
  266. String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(htmlMsg)) as typeof fetch
  267. const r = await fetchBody('hello@meridiandisplay.example', 'm2', CREDS, f)
  268. if ('error' in r) throw new Error('expected a body')
  269. expect(r.html).toHaveLength(400_000)
  270. // Truncated-and-labelled beats absent (rule 5), and the page says so.
  271. expect(r.htmlTruncated).toBe(true)
  272. expect(r.unread).toBe(true)
  273. })
  274. it('reports a failure rather than an empty message', async () => {
  275. // A body we could not fetch and a body that is genuinely blank are different
  276. // facts and must not render the same.
  277. const f = (async (url: string | URL) =>
  278. String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json({}, 404)) as typeof fetch
  279. const r = await fetchBody('hello@meridiandisplay.example', 'gone', CREDS, f)
  280. expect(r).toEqual({ error: 'Gmail returned HTTP 404 for that message.' })
  281. })
  282. it('surfaces a dead token with its cause rather than a bare failure', async () => {
  283. const f = (async () => json({ error: 'invalid_grant' }, 400)) as unknown as typeof fetch
  284. const r = await fetchBody('accounts@meridiandisplay.example', 'm1', CREDS, f)
  285. expect('error' in r && r.error).toMatch(/invalid_grant/)
  286. })
  287. it('caps a runaway body and says it did', async () => {
  288. const huge = { ...message, payload: { ...message.payload, parts: [{ mimeType: 'text/plain', body: { data: b64('x'.repeat(50_000)) } }] } }
  289. const f = (async (url: string | URL) =>
  290. String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(huge)) as typeof fetch
  291. const r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, f)
  292. if ('error' in r) throw new Error('unexpected')
  293. expect(r.truncated).toBe(true)
  294. expect(r.text.length).toBe(20_000)
  295. })
  296. })
  297. /**
  298. * The reply addressing fields.
  299. *
  300. * These are read here, on the server, because they are the only source of a
  301. * reply's recipient, subject and thread; the compose form carries none of
  302. * them. A form field a person can edit is a form field a request can set, and
  303. * this board's one irreversible action must not take its recipient from the
  304. * request that triggers it.
  305. */
  306. describe('fetchBody carries the threading headers', () => {
  307. const json = (b: unknown, status = 200) =>
  308. new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })
  309. const message = (headers: { name: string; value: string }[], threadId?: string) => ({
  310. id: 'm1',
  311. ...(threadId === undefined ? {} : { threadId }),
  312. internalDate: '1788210063000',
  313. payload: {
  314. mimeType: 'text/plain',
  315. headers: [{ name: 'From', value: 'Client <client@example.com>' }, ...headers],
  316. body: { data: b64('hi') },
  317. },
  318. })
  319. const fetchOf = (m: unknown) =>
  320. (async (url: string | URL) =>
  321. String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(m)) as unknown as typeof fetch
  322. it('reads Message-ID, References, Reply-To, Cc and the thread id', async () => {
  323. const body = await fetchBody(
  324. 'hello@meridiandisplay.example',
  325. 'm1',
  326. SA,
  327. fetchOf(
  328. message(
  329. [
  330. { name: 'Message-ID', value: '<abc@mail.example.com>' },
  331. { name: 'References', value: '<r1@x> <r2@x>' },
  332. { name: 'Reply-To', value: 'Billing <billing@example.com>' },
  333. { name: 'Cc', value: 'someone@example.com' },
  334. ],
  335. 't-99',
  336. ),
  337. ),
  338. )
  339. if ('error' in body) throw new Error(body.error)
  340. expect(body.messageId).toBe('<abc@mail.example.com>')
  341. expect(body.references).toBe('<r1@x> <r2@x>')
  342. expect(body.replyTo).toBe('Billing <billing@example.com>')
  343. expect(body.cc).toBe('someone@example.com')
  344. expect(body.threadId).toBe('t-99')
  345. })
  346. it('reports an absent header as null or empty string, never undefined', async () => {
  347. // The caller branches on these. `undefined` reads as a bug in the code
  348. // rather than as "the sender did not send one", and the two need very
  349. // different responses.
  350. const body = await fetchBody('hello@meridiandisplay.example', 'm1', SA, fetchOf(message([])))
  351. if ('error' in body) throw new Error(body.error)
  352. expect(body.messageId).toBeNull()
  353. expect(body.references).toBeNull()
  354. expect(body.replyTo).toBe('')
  355. expect(body.cc).toBe('')
  356. expect(body.threadId).toBe('')
  357. })
  358. })
  359. describe('fetchAttachment', () => {
  360. const json = (b: unknown, status = 200) =>
  361. new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })
  362. /** Token call, then the attachment call. Nothing touches the network. */
  363. const fetchOf = (res: Response, seen: string[] = []) =>
  364. (async (url: string | URL) => {
  365. const u = String(url)
  366. seen.push(u)
  367. return u.includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : res
  368. }) as unknown as typeof fetch
  369. const b64urlBytes = (bytes: number[]) => {
  370. let bin = ''
  371. for (const b of bytes) bin += String.fromCharCode(b)
  372. return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
  373. }
  374. it('asks the attachments endpoint for that message and hands back the bytes', async () => {
  375. const seen: string[] = []
  376. const got = await fetchAttachment(
  377. 'hello@meridiandisplay.example',
  378. 'm1',
  379. 'att-1',
  380. SA,
  381. fetchOf(json({ size: 6, data: b64urlBytes([0, 1, 2, 253, 254, 255]) }), seen),
  382. )
  383. expect(seen.some((u) => u.includes('/messages/m1/attachments/att-1'))).toBe(true)
  384. if ('error' in got) throw new Error(got.error)
  385. expect([...got.bytes]).toEqual([0, 1, 2, 253, 254, 255])
  386. })
  387. it('reports an HTTP failure rather than returning empty bytes', async () => {
  388. // The prime invariant on a download: a failed fetch and a zero-byte file
  389. // must not arrive at the caller looking the same.
  390. const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({}, 404)))
  391. expect('error' in got && got.error).toContain('404')
  392. })
  393. it('reports a 200 with no data as a failure', async () => {
  394. const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({ size: 10 })))
  395. expect('error' in got && got.error).toContain('no data')
  396. })
  397. it('refuses bytes over the ceiling even when the part under-declared its size', async () => {
  398. // The route checks the DECLARED size before calling; this checks what
  399. // actually arrived, so a message that lies about a part cannot walk past
  400. // the memory ceiling unnoticed.
  401. const big = b64urlBytes(Array.from({ length: ATTACHMENT_DOWNLOAD_CAP + 16 }, () => 65))
  402. const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({ data: big })))
  403. expect('error' in got && got.error).toContain('ceiling')
  404. })
  405. it('never throws; a token failure comes back as a result', async () => {
  406. const boom = (async () => {
  407. throw new Error('network is down')
  408. }) as unknown as typeof fetch
  409. const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, boom)
  410. expect('error' in got && got.error).toContain('network is down')
  411. })
  412. })