body.test.ts
Language TypeScript18.5 KB462 lines
/*** Reading a message body.** The risks of reading full bodies, and what is tested here: sender-controlled markup reaching the page, a body that* decodes to mojibake, and a walk that returns an attachment instead of the* message.*/import { describe, expect, it } from 'vitest'import { SA } from './sa.js'import {ATTACHMENT_DOWNLOAD_CAP,collectAttachments,decodeB64Url,decodeB64UrlBytes,extractBody,fetchAttachment,fetchBody,htmlToText,splitAddressList,} from '../src/index.js'const b64 = (s: string) =>btoa(String.fromCharCode(...new TextEncoder().encode(s))).replace(/\+/g, '-').replace(/\//g, '_')describe('decodeB64Url', () => {it('accepts Gmail’s URL-safe alphabet, which atob alone rejects', () => {expect(decodeB64Url(b64('a?b>c'))).toBe('a?b>c')})it('decodes UTF-8 rather than latin-1', () => {// atob gives bytes. Skipping the TextDecoder turns "café – €5" into mojibake// in every message a European vendor sends.expect(decodeB64Url(b64('café – €5 ✅'))).toBe('café – €5 ✅')})it('tolerates missing padding', () => {expect(decodeB64Url(b64('abc').replace(/=+$/, ''))).toBe('abc')})})describe('decodeB64UrlBytes', () => {it('returns the bytes, including the ones no text codec survives', () => {// 0xFD 0xFE 0xFF is invalid UTF-8. Through decodeB64Url each becomes// U+FFFD, which is a PDF the recipient cannot open, which is the whole reason// there are two decoders.const bytes = new Uint8Array([0, 1, 2, 253, 254, 255])let bin = ''for (const b of bytes) bin += String.fromCharCode(b)const b64url = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')expect([...decodeB64UrlBytes(b64url)]).toEqual([...bytes])})})describe('collectAttachments', () => {const part = (over: Record<string, unknown> = {}) => ({mimeType: 'application/pdf',filename: 'flyer.pdf',body: { attachmentId: 'a1', size: 2048 },...over,})it('returns nothing for a message with no filenamed parts', () => {expect(collectAttachments({ mimeType: 'text/plain', body: { data: 'x' } })).toEqual([])})it('indexes in walk order, which is what the download route addresses', () => {const got = collectAttachments({mimeType: 'multipart/mixed',parts: [part(), part({ filename: 'b.pdf' }), { mimeType: 'text/plain', body: { data: 'x' } }],})expect(got.map((a) => [a.index, a.name])).toEqual([[0, 'flyer.pdf'],[1, 'b.pdf'],])})it('reports an unmeasured size as null and never as 0', () => {const got = collectAttachments({ parts: [part({ body: { attachmentId: 'a1' } })] })expect(got[0]!.size).toBeNull()// A part Gmail says is genuinely empty is a real zero and stays one.const empty = collectAttachments({ parts: [part({ body: { attachmentId: 'a1', size: 0 } })] })expect(empty[0]!.size).toBe(0)})it('marks an inline image inline, by the same signals a mail client uses', () => {// A multi-party client thread can carry four of these: signature logos.// Counted as attachments they read as "4 attachments" on a message with none.const byDisposition = collectAttachments({parts: [part({filename: 'image001.jpg',mimeType: 'image/jpeg',headers: [{ name: 'Content-Disposition', value: 'inline; filename="image001.jpg"' }],}),],})expect(byDisposition[0]!.inline).toBe(true)const byCid = collectAttachments({parts: [part({ filename: 'logo.png', headers: [{ name: 'Content-ID', value: '<logo>' }] })],})expect(byCid[0]!.inline).toBe(true)})it('does not call an attached file inline just because it has a disposition', () => {const got = collectAttachments({parts: [part({ headers: [{ name: 'Content-Disposition', value: 'attachment; filename="flyer.pdf"' }] })],})expect(got[0]!.inline).toBe(false)})it('records a part with no attachmentId as having none, rather than skipping it', () => {// Rule 9: a file that cannot be fetched is still a file that is there, and// the page says why there is no link.const got = collectAttachments({ parts: [part({ body: { size: 10 } })] })expect(got[0]!.attachmentId).toBeNull()})})describe('splitAddressList', () => {it('does not split inside a quoted display name', () => {// The client-thread case: `"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>`.// A split on ',' makes that two entries, one of which is an address that// would be sent to.expect(splitAddressList('"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>, sasha@lakeside-expo.example')).toEqual(['"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>','sasha@lakeside-expo.example',])})it('does not split inside angle brackets, and drops empty entries', () => {expect(splitAddressList('a@b.com,, <c@d.com>')).toEqual(['a@b.com', '<c@d.com>'])expect(splitAddressList('')).toEqual([])expect(splitAddressList(' ')).toEqual([])})})describe('htmlToText', () => {it('drops the CONTENTS of style and script, not just their tags', () => {// Stripping tags alone leaves a wall of CSS in the middle of the message,// which is most of a marketing email by volume.const out = htmlToText('<style>.a{color:red}</style><p>Hello</p><script>alert(1)</script>')expect(out).toBe('Hello')})it('strips the zero-width filler marketing uses to pad preview text', () => {// Every promotional snippet in the live inbox is "͏ ͏ ͏ ͏" for hundreds of// characters. Left in, the message reads as a screen of blank lines.const padded = 'Real text' + ''.repeat(200)expect(htmlToText(padded)).toBe('Real text')})it('turns block ends into line breaks so paragraphs survive', () => {expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\ntwo')})it('unescapes entities', () => {expect(htmlToText('<p>Tom & Jerry <3 "x"</p>')).toBe('Tom & Jerry <3 "x"')})it('leaves no markup behind for the page to interpret', () => {const out = htmlToText('<img src=x onerror="alert(1)"><b>hi</b>')expect(out).not.toContain('<')expect(out).not.toContain('onerror')})})describe('extractBody', () => {const part = (mimeType: string, text: string, extra: Record<string, unknown> = {}) => ({mimeType,body: { data: b64(text) },...extra,})it('prefers text/plain over the HTML alternative', () => {const out = extractBody({mimeType: 'multipart/alternative',parts: [part('text/plain', 'plain version'), part('text/html', '<p>html version</p>')],})// The raw HTML rides along even when plain wins the text field; the frame// route renders it.expect(out).toEqual({ text: 'plain version', kind: 'plain', html: '<p>html version</p>' })})it('falls back to HTML converted to text', () => {const out = extractBody({ mimeType: 'multipart/alternative', parts: [part('text/html', '<p>only html</p>')] })expect(out).toEqual({ text: 'only html', kind: 'html', html: '<p>only html</p>' })})it('digs through nested multiparts', () => {const out = extractBody({mimeType: 'multipart/mixed',parts: [{ mimeType: 'multipart/alternative', parts: [part('text/plain', 'buried')] },part('application/pdf', 'not text', { filename: 'invoice.pdf' }),],})expect(out.text).toBe('buried')})it('never returns an attachment as the body', () => {const out = extractBody({mimeType: 'multipart/mixed',parts: [part('text/plain', 'attached note', { filename: 'note.txt' })],})expect(out).toEqual({ text: '', kind: 'none', html: '' })})it('reports an image-only message as having no text, not as blank', () => {expect(extractBody({ mimeType: 'multipart/mixed', parts: [part('image/png', 'x')] }).kind).toBe('none')})})describe('fetchBody', () => {const CREDS = SAconst json = (b: unknown, status = 200) =>new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })const message = {id: 'm1',internalDate: '1788210063000',payload: {mimeType: 'multipart/mixed',headers: [{ name: 'From', value: 'Upscaler Labs <news@upscaler.vendor.example>' },{ name: 'Subject', value: 'What is new this month' },{ name: 'To', value: 'hello@meridiandisplay.example' },],parts: [{ mimeType: 'text/plain', body: { data: b64('Body text here.') } },{ mimeType: 'application/pdf', filename: 'flyer.pdf', body: { attachmentId: 'a1' } },],},}const ok = (async (url: string | URL) =>String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(message)) as typeof fetchit('asks for the full message, unlike the list', async () => {const seen: string[] = []const f = (async (url: string | URL) => {seen.push(String(url))return String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(message)}) as unknown as typeof fetchawait fetchBody('hello@meridiandisplay.example', 'm1', CREDS, f)expect(seen.find((u) => u.includes('/messages/m1'))).toContain('format=full')})it('returns the text, the headers, and each attachment with its handle', async () => {const r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, ok)expect('error' in r).toBe(false)if ('error' in r) returnexpect(r.text).toBe('Body text here.')expect(r.sender).toBe('news@upscaler.vendor.example')expect(r.to).toBe('hello@meridiandisplay.example')// Attachments carry an id and a size, not just a name: a name cannot be// fetched with and cannot be size-checked before fetching.expect(r.attachments).toEqual([{index: 0,name: 'flyer.pdf',mimeType: 'application/pdf',// The fixture part carries no `size`, so it is null and NOT 0; the// page prints "size not reported" rather than claiming an empty file.size: null,attachmentId: 'a1',inline: false,},])})it('carries html:null for a plain-only message, and Gmail unread state', async () => {const r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, ok)if ('error' in r) throw new Error('expected a body')// No HTML part → null, not ''; the page decides frame-vs-text from this.expect(r.html).toBeNull()expect(r.htmlTruncated).toBe(false)expect(r.unread).toBe(false) // no labelIds in the fixture message})it('keeps the raw HTML for the frame route, capped and labelled when cut', async () => {// The spread in b64() blows the stack past ~100k chars; chunk it here.const bigRaw = '<p>' + 'x'.repeat(500_000) + '</p>'const bytes = new TextEncoder().encode(bigRaw)let bin = ''for (let i = 0; i < bytes.length; i += 8192) bin += String.fromCharCode(...bytes.subarray(i, i + 8192))const bigB64 = btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')const htmlMsg = {id: 'm2',internalDate: '1788210063000',labelIds: ['UNREAD', 'INBOX'],payload: {mimeType: 'multipart/alternative',headers: [{ name: 'From', value: 'a@b.com' }],parts: [{ mimeType: 'text/html', body: { data: bigB64 } }],},}const f = (async (url: string | URL) =>String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(htmlMsg)) as typeof fetchconst r = await fetchBody('hello@meridiandisplay.example', 'm2', CREDS, f)if ('error' in r) throw new Error('expected a body')expect(r.html).toHaveLength(400_000)// Truncated-and-labelled beats absent (rule 5), and the page says so.expect(r.htmlTruncated).toBe(true)expect(r.unread).toBe(true)})it('reports a failure rather than an empty message', async () => {// A body we could not fetch and a body that is genuinely blank are different// facts and must not render the same.const f = (async (url: string | URL) =>String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json({}, 404)) as typeof fetchconst r = await fetchBody('hello@meridiandisplay.example', 'gone', CREDS, f)expect(r).toEqual({ error: 'Gmail returned HTTP 404 for that message.' })})it('surfaces a dead token with its cause rather than a bare failure', async () => {const f = (async () => json({ error: 'invalid_grant' }, 400)) as unknown as typeof fetchconst r = await fetchBody('accounts@meridiandisplay.example', 'm1', CREDS, f)expect('error' in r && r.error).toMatch(/invalid_grant/)})it('caps a runaway body and says it did', async () => {const huge = { ...message, payload: { ...message.payload, parts: [{ mimeType: 'text/plain', body: { data: b64('x'.repeat(50_000)) } }] } }const f = (async (url: string | URL) =>String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(huge)) as typeof fetchconst r = await fetchBody('hello@meridiandisplay.example', 'm1', CREDS, f)if ('error' in r) throw new Error('unexpected')expect(r.truncated).toBe(true)expect(r.text.length).toBe(20_000)})})/*** The reply addressing fields.** These are read here, on the server, because they are the only source of a* reply's recipient, subject and thread; the compose form carries none of* them. A form field a person can edit is a form field a request can set, and* this board's one irreversible action must not take its recipient from the* request that triggers it.*/describe('fetchBody carries the threading headers', () => {const json = (b: unknown, status = 200) =>new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })const message = (headers: { name: string; value: string }[], threadId?: string) => ({id: 'm1',...(threadId === undefined ? {} : { threadId }),internalDate: '1788210063000',payload: {mimeType: 'text/plain',headers: [{ name: 'From', value: 'Client <client@example.com>' }, ...headers],body: { data: b64('hi') },},})const fetchOf = (m: unknown) =>(async (url: string | URL) =>String(url).includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : json(m)) as unknown as typeof fetchit('reads Message-ID, References, Reply-To, Cc and the thread id', async () => {const body = await fetchBody('hello@meridiandisplay.example','m1',SA,fetchOf(message([{ name: 'Message-ID', value: '<abc@mail.example.com>' },{ name: 'References', value: '<r1@x> <r2@x>' },{ name: 'Reply-To', value: 'Billing <billing@example.com>' },{ name: 'Cc', value: 'someone@example.com' },],'t-99',),),)if ('error' in body) throw new Error(body.error)expect(body.messageId).toBe('<abc@mail.example.com>')expect(body.references).toBe('<r1@x> <r2@x>')expect(body.replyTo).toBe('Billing <billing@example.com>')expect(body.cc).toBe('someone@example.com')expect(body.threadId).toBe('t-99')})it('reports an absent header as null or empty string, never undefined', async () => {// The caller branches on these. `undefined` reads as a bug in the code// rather than as "the sender did not send one", and the two need very// different responses.const body = await fetchBody('hello@meridiandisplay.example', 'm1', SA, fetchOf(message([])))if ('error' in body) throw new Error(body.error)expect(body.messageId).toBeNull()expect(body.references).toBeNull()expect(body.replyTo).toBe('')expect(body.cc).toBe('')expect(body.threadId).toBe('')})})describe('fetchAttachment', () => {const json = (b: unknown, status = 200) =>new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } })/** Token call, then the attachment call. Nothing touches the network. */const fetchOf = (res: Response, seen: string[] = []) =>(async (url: string | URL) => {const u = String(url)seen.push(u)return u.includes('oauth2') ? json({ access_token: 't', expires_in: 3599 }) : res}) as unknown as typeof fetchconst b64urlBytes = (bytes: number[]) => {let bin = ''for (const b of bytes) bin += String.fromCharCode(b)return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}it('asks the attachments endpoint for that message and hands back the bytes', async () => {const seen: string[] = []const got = await fetchAttachment('hello@meridiandisplay.example','m1','att-1',SA,fetchOf(json({ size: 6, data: b64urlBytes([0, 1, 2, 253, 254, 255]) }), seen),)expect(seen.some((u) => u.includes('/messages/m1/attachments/att-1'))).toBe(true)if ('error' in got) throw new Error(got.error)expect([...got.bytes]).toEqual([0, 1, 2, 253, 254, 255])})it('reports an HTTP failure rather than returning empty bytes', async () => {// The prime invariant on a download: a failed fetch and a zero-byte file// must not arrive at the caller looking the same.const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({}, 404)))expect('error' in got && got.error).toContain('404')})it('reports a 200 with no data as a failure', async () => {const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({ size: 10 })))expect('error' in got && got.error).toContain('no data')})it('refuses bytes over the ceiling even when the part under-declared its size', async () => {// The route checks the DECLARED size before calling; this checks what// actually arrived, so a message that lies about a part cannot walk past// the memory ceiling unnoticed.const big = b64urlBytes(Array.from({ length: ATTACHMENT_DOWNLOAD_CAP + 16 }, () => 65))const got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, fetchOf(json({ data: big })))expect('error' in got && got.error).toContain('ceiling')})it('never throws; a token failure comes back as a result', async () => {const boom = (async () => {throw new Error('network is down')}) as unknown as typeof fetchconst got = await fetchAttachment('hello@meridiandisplay.example', 'm1', 'a', SA, boom)expect('error' in got && got.error).toContain('network is down')})})