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

send.test.ts

Language TypeScript15.9 KB363 lines

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

  1. /**
  2. * Sending a reply: the only outbound write this board makes.
  3. *
  4. * Nothing here touches the network: every test injects a fake fetch. The first
  5. * real send is made by hand; a build that could send a real email as a side
  6. * effect of a test run is not a build anyone should trust with a client's inbox.
  7. *
  8. * What is worth testing is not "does it POST"; it is the handful of things
  9. * that would produce a technically-successful send that is wrong: a subject
  10. * that stacks "Re: Re: Re:", a thread id sent to the wrong mailbox, an accented
  11. * body that arrives as mojibake, and a failure that names one cause instead of
  12. * listing the candidates.
  13. */
  14. import { describe, expect, it } from 'vitest'
  15. import {
  16. buildRawMessage,
  17. encodeHeaderWord,
  18. mimeParam,
  19. rawToB64Url,
  20. replySubject,
  21. safeMimeType,
  22. sendMessage,
  23. textToHtml,
  24. type OutgoingMessage,
  25. } from '../src/index.js'
  26. const CRED = { clientId: 'id', clientSecret: 'sec', refreshToken: 'rt' }
  27. const json = (body: unknown, status = 200) =>
  28. new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
  29. /** Token call first, then the send call. Both faked. */
  30. const fakeFetch = (sendResponse: Response, calls: { url: string; body: string }[] = []) =>
  31. (async (url: string | URL | Request, init?: RequestInit) => {
  32. const u = String(url)
  33. calls.push({ url: u, body: String(init?.body ?? '') })
  34. if (u.includes('oauth2.googleapis.com/token')) {
  35. return json({ access_token: 'tok', expires_in: 3599, scope: 'https://www.googleapis.com/auth/gmail.send' })
  36. }
  37. return sendResponse
  38. }) as unknown as typeof fetch
  39. const reply = (over: Partial<OutgoingMessage> = {}): OutgoingMessage => ({
  40. from: 'hello@meridiandisplay.example',
  41. to: 'client@example.com',
  42. cc: null,
  43. subject: 'Re: Banner proof',
  44. text: 'Proof attached in Gmail.',
  45. signatureHtml: null,
  46. inReplyTo: '<abc@mail.example.com>',
  47. references: '<abc@mail.example.com>',
  48. threadId: null,
  49. attachments: [],
  50. ...over,
  51. })
  52. /** A file part, with bytes that are not text so a text path would corrupt it. */
  53. const bin = (name = 'proof.pdf', bytes = new Uint8Array([0, 1, 2, 253, 254, 255])) => ({
  54. name,
  55. mimeType: 'application/pdf',
  56. bytes,
  57. })
  58. /**
  59. * Is there a header line of this name anywhere in the message?
  60. *
  61. * A line test, not a substring test. `Cc: a@b.com Bcc: v@x.com` contains the
  62. * text "Bcc:" and is harmless; the injection is what makes it a header, which
  63. * means starting a line. An `expect(raw).not.toContain('Bcc:')` passes only
  64. * until someone folds a value, and then it fails on a message that is fine.
  65. */
  66. const hasHeader = (raw: string, name: string): boolean =>
  67. raw.split('\r\n').some((l) => new RegExp(`^${name}:`, 'i').test(l))
  68. /** The named boundary's parts, headers and raw body kept separate. */
  69. const sections = (raw: string, boundary: string): { head: string; body: string }[] =>
  70. raw
  71. .split(`--${boundary}`)
  72. .slice(1, -1)
  73. .map((p) => {
  74. const i = p.indexOf('\r\n\r\n')
  75. return { head: p.slice(0, i), body: p.slice(i + 4) }
  76. })
  77. const boundaryOf = (raw: string, type: string): string =>
  78. new RegExp(`Content-Type: ${type}; boundary="([^"]+)"`).exec(raw)![1]!
  79. /** Pull one base64 body part out of the raw message and decode it. */
  80. const parts = (raw: string): string[] => {
  81. const m = /boundary="([^"]+)"/.exec(raw)
  82. if (!m) return []
  83. return raw
  84. .split(`--${m[1]!}`)
  85. .slice(1, -1)
  86. .map((p) => {
  87. const b64 = p.split('\r\n\r\n').slice(1).join('\r\n\r\n').trim().replace(/\r\n/g, '')
  88. return new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))
  89. })
  90. }
  91. describe('replySubject', () => {
  92. it('adds Re: once', () => {
  93. expect(replySubject('Banner proof')).toBe('Re: Banner proof')
  94. })
  95. it('is idempotent, whatever the case or spacing', () => {
  96. // Three replies deep, a naive prefix gives "Re: Re: Re: Banner proof" and
  97. // the client sees the board talking to itself.
  98. for (const s of ['Re: Banner proof', 'RE: Banner proof', 're:Banner proof', ' Re : x']) {
  99. expect(replySubject(s)).toBe(replySubject(replySubject(s)))
  100. }
  101. expect(replySubject('Re: Banner proof')).toBe('Re: Banner proof')
  102. })
  103. it('names an empty subject rather than sending a bare "Re:"', () => {
  104. expect(replySubject(' ')).toBe('Re: (no subject)')
  105. })
  106. })
  107. describe('encodeHeaderWord', () => {
  108. it('leaves printable ASCII alone', () => {
  109. // Encoding everything would make every subject unreadable in the raw source
  110. // for no gain.
  111. expect(encodeHeaderWord('Banner proof #4')).toBe('Banner proof #4')
  112. })
  113. it('RFC 2047-encodes anything that is not', () => {
  114. for (const s of ['Café résumé', 'Año nuevo 🎉', '设计稿']) {
  115. const out = encodeHeaderWord(s)
  116. expect(out).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/)
  117. const b64 = out.slice('=?UTF-8?B?'.length, -2)
  118. expect(new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))).toBe(s)
  119. }
  120. })
  121. it('cannot carry a newline into the header block', () => {
  122. // Header injection: a CR or LF in a value turns one header into two, and
  123. // the value that reaches here came off a message a stranger sent us.
  124. const out = encodeHeaderWord('Subject\r\nBcc: attacker@example.com')
  125. expect(out).not.toContain('\r')
  126. expect(out).not.toContain('\n')
  127. })
  128. })
  129. describe('buildRawMessage', () => {
  130. it('carries In-Reply-To and References when given, and omits them when null', () => {
  131. expect(buildRawMessage(reply())).toContain('In-Reply-To: <abc@mail.example.com>')
  132. const bare = buildRawMessage(reply({ inReplyTo: null, references: null }))
  133. expect(bare).not.toContain('In-Reply-To:')
  134. expect(bare).not.toContain('References:')
  135. })
  136. it('is multipart/alternative with both a text and an HTML part', () => {
  137. const raw = buildRawMessage(reply())
  138. expect(raw).toContain('Content-Type: multipart/alternative; boundary="')
  139. expect(raw).toContain('Content-Type: text/plain; charset=UTF-8')
  140. expect(raw).toContain('Content-Type: text/html; charset=UTF-8')
  141. expect(parts(raw)).toHaveLength(2)
  142. })
  143. it('round-trips a non-ASCII body byte for byte', () => {
  144. // The whole reason both encoders go through TextEncoder: btoa on a string
  145. // containing 'é' throws, and a "fix" that drops to latin-1 silently sends
  146. // mojibake to the client instead.
  147. const text = 'Hola Begoña, el diseño está listo 🎉 设计稿'
  148. const raw = buildRawMessage(reply({ text }))
  149. expect(parts(raw)[0]).toBe(text)
  150. const back = new TextDecoder().decode(
  151. Uint8Array.from(atob(rawToB64Url(raw).replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - (rawToB64Url(raw).length % 4)) % 4)), (c) => c.charCodeAt(0)),
  152. )
  153. expect(back).toBe(raw)
  154. })
  155. it('puts the signature in BOTH parts when given, and neither when not', () => {
  156. const sig = '<p><b>Meridian Display</b> · Northside office</p>'
  157. const withSig = buildRawMessage(reply({ signatureHtml: sig }))
  158. const [plain, html] = parts(withSig)
  159. expect(html).toContain(sig)
  160. // The plain part gets the same signature as text, from the same converter
  161. // the reader sees, not a second rendering to keep in step by hand.
  162. expect(plain).toContain('Meridian Display')
  163. expect(plain).toContain('\r\n-- \r\n')
  164. const [p2, h2] = parts(buildRawMessage(reply()))
  165. expect(p2).not.toContain('--')
  166. expect(h2).not.toContain('Meridian Display')
  167. })
  168. it('uses CRLF throughout, which is what RFC 2822 requires', () => {
  169. const raw = buildRawMessage(reply())
  170. expect(raw).toContain('\r\n')
  171. expect(raw.replace(/\r\n/g, '')).not.toContain('\n')
  172. })
  173. it('writes a Cc header only when there is one', () => {
  174. expect(buildRawMessage(reply({ cc: 'boss@example.com' }))).toContain('Cc: boss@example.com')
  175. // null and '' must both mean "no Cc": an empty Cc header is legal and
  176. // reads, in a Sent folder, as a Cc sent to nobody.
  177. expect(buildRawMessage(reply({ cc: null }))).not.toContain('Cc:')
  178. expect(buildRawMessage(reply({ cc: '' }))).not.toContain('Cc:')
  179. })
  180. it('cannot be made to inject a header through the Cc', () => {
  181. const raw = buildRawMessage(reply({ cc: 'a@b.com\r\nBcc: victim@elsewhere.com' }))
  182. // The CRLF is flattened to a space, so the whole thing stays one Cc value
  183. // and no Bcc header exists. (The Worker's parseRecipients refuses this
  184. // input outright; the encoder is the second line of defence, not the only.)
  185. expect(hasHeader(raw, 'Bcc')).toBe(false)
  186. expect(raw).toContain('Cc: a@b.com Bcc: victim@elsewhere.com')
  187. })
  188. })
  189. describe('buildRawMessage with attachments', () => {
  190. it('is byte-identical to the old shape when there are none', () => {
  191. // The reason `mixed` is conditional. A mixed wrapper around a single
  192. // alternative part renders the same, but this is the one output nobody can
  193. // inspect after the fact, so nothing changes for a message that asked for
  194. // no change.
  195. const raw = buildRawMessage(reply())
  196. expect(raw).toContain('Content-Type: multipart/alternative; boundary="')
  197. expect(raw).not.toContain('multipart/mixed')
  198. })
  199. it('wraps the alternative in a mixed part and adds one part per file', () => {
  200. const raw = buildRawMessage(reply({ attachments: [bin(), bin('quote.pdf')] }))
  201. const mixed = boundaryOf(raw, 'multipart/mixed')
  202. const secs = sections(raw, mixed)
  203. expect(secs).toHaveLength(3)
  204. expect(secs[0]!.head).toContain('multipart/alternative')
  205. expect(secs[1]!.head).toContain('Content-Disposition: attachment; filename="proof.pdf"')
  206. expect(secs[2]!.head).toContain('filename="quote.pdf"')
  207. // The text and HTML parts are still both there, nested inside the first.
  208. const alt = boundaryOf(raw, 'multipart/alternative')
  209. expect(sections(raw, alt)).toHaveLength(2)
  210. })
  211. it('round-trips the file bytes exactly, including ones no text codec survives', () => {
  212. // 0xFD 0xFE 0xFF is not valid UTF-8. Through a text path each becomes
  213. // U+FFFD and the recipient gets a file that opens as garbage, which is
  214. // why there is a separate bytes encoder at all.
  215. const bytes = new Uint8Array([0, 1, 2, 253, 254, 255, 10, 13])
  216. const raw = buildRawMessage(reply({ attachments: [bin('x.bin', bytes)] }))
  217. const mixed = boundaryOf(raw, 'multipart/mixed')
  218. const body = sections(raw, mixed)[1]!.body.trim().replace(/\r\n/g, '')
  219. expect([...Uint8Array.from(atob(body), (c) => c.charCodeAt(0))]).toEqual([...bytes])
  220. })
  221. it('wraps the base64 at 76 columns like every other part', () => {
  222. const raw = buildRawMessage(reply({ attachments: [bin('big.bin', new Uint8Array(1000))] }))
  223. const mixed = boundaryOf(raw, 'multipart/mixed')
  224. const lines = sections(raw, mixed)[1]!.body.trim().split('\r\n')
  225. expect(lines.length).toBeGreaterThan(1)
  226. expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(76)
  227. })
  228. it('a filename cannot end the parameter or forge a header', () => {
  229. // The filename is chosen by whoever picked the file and goes straight into
  230. // Content-Disposition. A quote closes the parameter; a CRLF starts a header.
  231. const raw = buildRawMessage(reply({ attachments: [bin('a".pdf\r\nBcc: v@x.com')] }))
  232. expect(hasHeader(raw, 'Bcc')).toBe(false)
  233. expect(raw).toContain('filename="a.pdfBcc: v@x.com"')
  234. })
  235. it('RFC 2047-encodes a non-ASCII filename rather than mangling it', () => {
  236. const raw = buildRawMessage(reply({ attachments: [bin('diseño.pdf')] }))
  237. expect(raw).toContain('filename="=?UTF-8?B?')
  238. expect(raw).not.toContain('diseño.pdf')
  239. })
  240. it('refuses to pass through a mime type that is not one', () => {
  241. expect(safeMimeType('application/pdf')).toBe('application/pdf')
  242. expect(safeMimeType('text/html; charset=utf-8')).toBe('text/html')
  243. expect(safeMimeType('nonsense')).toBe('application/octet-stream')
  244. expect(safeMimeType('a/b\r\nX: y')).toBe('application/octet-stream')
  245. expect(safeMimeType('')).toBe('application/octet-stream')
  246. })
  247. it('never lets a blank filename produce a nameless part', () => {
  248. expect(mimeParam(' ')).toBe('attachment')
  249. })
  250. })
  251. describe('textToHtml', () => {
  252. it('escapes markup rather than letting typed text become tags', () => {
  253. expect(textToHtml('<b>hi</b> & "x"')).toContain('&lt;b&gt;hi&lt;/b&gt; &amp; &quot;x&quot;')
  254. })
  255. it('turns newlines into breaks', () => {
  256. expect(textToHtml('a\nb')).toContain('a<br>b')
  257. })
  258. })
  259. describe('sendMessage', () => {
  260. it('POSTs raw to the send endpoint and reports the ids back', async () => {
  261. const calls: { url: string; body: string }[] = []
  262. const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ id: 'm1', threadId: 't1' }), calls))
  263. expect(res).toEqual({ ok: true, id: 'm1', threadId: 't1' })
  264. const send = calls.find((c) => c.url.includes('/messages/send'))!
  265. expect(send.url).toBe('https://gmail.googleapis.com/gmail/v1/users/me/messages/send')
  266. expect(JSON.parse(send.body).raw).toBeTruthy()
  267. })
  268. it('includes threadId only when one was given', async () => {
  269. // Gmail thread ids are PER-MAILBOX. Sending hello@'s id through accounts@'s
  270. // token is rejected outright, so decideReply omits it when the sending
  271. // mailbox differs, and this pins that the encoder honours the omission.
  272. const a: { url: string; body: string }[] = []
  273. await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ id: 'm', threadId: 't' }), a))
  274. expect(JSON.parse(a.find((c) => c.url.includes('send'))!.body).threadId).toBeUndefined()
  275. const b: { url: string; body: string }[] = []
  276. await sendMessage('hello@meridiandisplay.example', CRED, reply({ threadId: 'thr1' }), fakeFetch(json({ id: 'm', threadId: 'thr1' }), b))
  277. expect(JSON.parse(b.find((c) => c.url.includes('send'))!.body).threadId).toBe('thr1')
  278. })
  279. it('names several candidates for a 403 and picks none of them', async () => {
  280. // Rule 1. A 403 here has at least five plausible causes and the
  281. // expensive one ("the token is fine, re-mint it" vs "consent screen is in
  282. // Testing") is not distinguishable from the response.
  283. const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ error: { message: 'Request had insufficient authentication scopes.' } }, 403)))
  284. expect(res.ok).toBe(false)
  285. if (res.ok) throw new Error('unreachable')
  286. expect(res.status).toBe(403)
  287. expect(res.candidates.length).toBeGreaterThanOrEqual(3)
  288. expect(res.error).toContain('403')
  289. // No candidate is marked as the likely one.
  290. expect(res.candidates.join(' ')).not.toMatch(/most likely|probably/i)
  291. })
  292. it('never throws on a network failure; it returns the failure', async () => {
  293. // Same contract as readMailbox, for a sharper reason: the caller has an
  294. // audit row to finish writing, and a thrown error is a send whose outcome
  295. // nobody recorded.
  296. const f = (async () => {
  297. throw new Error('connection reset')
  298. }) as unknown as typeof fetch
  299. const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), f)
  300. expect(res.ok).toBe(false)
  301. if (res.ok) throw new Error('unreachable')
  302. expect(res.error).toContain('connection reset')
  303. expect(res.status).toBeNull()
  304. })
  305. it('treats a 200 with no message id as a failure, not a success', async () => {
  306. // "sent" beside a message nobody can find in Gmail is worse than an error.
  307. const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({})))
  308. expect(res.ok).toBe(false)
  309. if (res.ok) throw new Error('unreachable')
  310. expect(res.candidates.join(' ')).toMatch(/Sent folder/i)
  311. })
  312. it('reports a token failure without ever reaching the send endpoint', async () => {
  313. const calls: { url: string; body: string }[] = []
  314. const f = (async (url: string | URL | Request) => {
  315. calls.push({ url: String(url), body: '' })
  316. return json({ error: 'invalid_grant' }, 400)
  317. }) as unknown as typeof fetch
  318. const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), f)
  319. expect(res.ok).toBe(false)
  320. expect(calls.some((c) => c.url.includes('/messages/send'))).toBe(false)
  321. })
  322. })