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

gmail.test.ts

Language TypeScript34.0 KB759 lines

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

  1. /**
  2. * The Gmail reader.
  3. *
  4. * What is worth testing here is not "does it call the API"; it is the three
  5. * things that would quietly corrupt the board: an address parsed wrong so a
  6. * filter matches the wrong sender, a dead mailbox rendered as an empty one, and
  7. * a message body reaching KV.
  8. */
  9. import { describe, expect, it, vi } from 'vitest'
  10. import { SA } from './sa.js'
  11. import {
  12. accessTokenFor,
  13. cleanSnippet,
  14. GMAIL_SCOPE,
  15. GMAIL_SCOPES,
  16. GMAIL_SEND_SCOPE,
  17. GmailAuthError,
  18. scopesGranted,
  19. normalizeAddress,
  20. parseFrom,
  21. parseServiceAccountSecret,
  22. readMailbox,
  23. buildBatchBody,
  24. parseBatchResponse,
  25. } from '../src/index.js'
  26. const CREDS = SA
  27. const json = (body: unknown, status = 200) =>
  28. new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
  29. describe('parseFrom', () => {
  30. it('splits a display name from the address', () => {
  31. expect(parseFrom('Upscaler Labs <news@upscaler.vendor.example>')).toEqual({
  32. sender: 'news@upscaler.vendor.example',
  33. senderName: 'Upscaler Labs',
  34. })
  35. })
  36. it('handles a bare address', () => {
  37. expect(parseFrom('noreply@qrcodes.vendor.example')).toEqual({ sender: 'noreply@qrcodes.vendor.example', senderName: '' })
  38. })
  39. it('handles a quoted name containing a comma', () => {
  40. // Splitting on ',' or on the first '<' both get this wrong, and the result
  41. // is a filter entry that matches nothing.
  42. expect(parseFrom('"Labs, Upscaler" <news@upscaler.vendor.example>')).toEqual({
  43. sender: 'news@upscaler.vendor.example',
  44. senderName: 'Labs, Upscaler',
  45. })
  46. })
  47. })
  48. describe('normalizeAddress', () => {
  49. it('lowercases, so a filter matches whatever case the header used', () => {
  50. // Live: TrackingUpdates@courier.vendor.example. Case-sensitive matching would let a
  51. // sender slip the filter by changing capitalisation.
  52. expect(normalizeAddress('TrackingUpdates@courier.vendor.example')).toBe('trackingupdates@courier.vendor.example')
  53. })
  54. it('strips a plus-tag so a per-account sender is one row, not hundreds', () => {
  55. // Live: failed-payments+acct_1ExampleAcct0000@stripe.com. Left whole, every
  56. // Stripe account becomes its own "sender" and the button applies to one.
  57. expect(normalizeAddress('failed-payments+acct_1ExampleAcct0000@stripe.com')).toBe(
  58. 'failed-payments@stripe.com',
  59. )
  60. })
  61. it('leaves a + in the domain alone', () => {
  62. expect(normalizeAddress('a@b+c.com')).toBe('a@b+c.com')
  63. })
  64. })
  65. describe('parseServiceAccountSecret: read the secret however it was pasted', () => {
  66. // The secret often holds the whole downloaded key JSON, not the bare
  67. // private_key. Pasting the file is the natural move at a prompt that says
  68. // "enter a secret value", so the natural move has to work.
  69. const KEY_JSON = JSON.stringify({
  70. type: 'service_account',
  71. client_email: 'meridian-dashboard@meridian-ops.iam.gserviceaccount.com',
  72. private_key: SA.privateKey,
  73. token_uri: 'https://oauth2.googleapis.com/token',
  74. })
  75. it('accepts the entire key JSON and yields both key and email', () => {
  76. const out = parseServiceAccountSecret(KEY_JSON)
  77. expect(out.email).toBe('meridian-dashboard@meridian-ops.iam.gserviceaccount.com')
  78. expect(out.privateKey).toBe(SA.privateKey)
  79. })
  80. it('accepts a bare PEM, with no email', () => {
  81. // Trailing whitespace is trimmed; the key material must survive intact.
  82. expect(parseServiceAccountSecret(SA.privateKey)).toEqual({
  83. email: null,
  84. privateKey: SA.privateKey.trim(),
  85. })
  86. })
  87. it('unwraps a PEM someone quoted', () => {
  88. expect(parseServiceAccountSecret(`"${SA.privateKey.trim()}"`).privateKey).toBe(SA.privateKey.trim())
  89. })
  90. it('rejects an OAuth client secret with a message that names the confusion', () => {
  91. // A plausible mistake: GMAIL_SA_KEY holding the GOCSPX OAuth client secret,
  92. // because it was on screen when the prompt appeared. Nothing can recover a
  93. // key that was never uploaded, so the error's job is to say which
  94. // credential this is and which it needs.
  95. expect(() => parseServiceAccountSecret('GOCSPX-AbCdEfGhIjKlMnOp')).toThrow(/OAuth client secret/)
  96. })
  97. it('names a truncated-JSON paste and the file-redirect fix', () => {
  98. // Half a paste is the other likely failure, and "not valid base64" pointed
  99. // nowhere. The error must carry the actionable command.
  100. expect(() => parseServiceAccountSecret('{"client_email": "a@b.c", "priv')).toThrow(/< key\.json/)
  101. })
  102. it('says when the JSON is not a key file at all', () => {
  103. expect(() => parseServiceAccountSecret('{"foo": 1}')).toThrow(/no private_key/)
  104. })
  105. it('a full-JSON secret is a complete configuration for the token exchange', async () => {
  106. const parsed = parseServiceAccountSecret(KEY_JSON)
  107. const f = (async () => new Response(JSON.stringify({ access_token: 'tok', expires_in: 3599 }), { status: 200 })) as unknown as typeof fetch
  108. await expect(
  109. accessTokenFor('hello@meridiandisplay.example', { email: parsed.email!, privateKey: parsed.privateKey }, f),
  110. ).resolves.toMatchObject({ accessToken: 'tok' })
  111. })
  112. })
  113. describe('token exchange', () => {
  114. it('signs a real JWT and impersonates the right mailbox', async () => {
  115. // The claim set is the whole mechanism: `sub` is the mailbox being read and
  116. // is what domain-wide delegation authorizes. A token minted with the wrong
  117. // sub reads the wrong person's mail.
  118. let sent = ''
  119. const f = (async (_u: string | URL, init?: RequestInit) => {
  120. sent = String(init?.body)
  121. return json({ access_token: 'tok', expires_in: 3599 })
  122. }) as unknown as typeof fetch
  123. await accessTokenFor('accounts@meridiandisplay.example', SA, f)
  124. const assertion = new URLSearchParams(sent).get('assertion')!
  125. expect(new URLSearchParams(sent).get('grant_type')).toBe(
  126. 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  127. )
  128. const [h, c, sig] = assertion.split('.')
  129. const dec = (p: string) => JSON.parse(Buffer.from(p, 'base64url').toString('utf8'))
  130. expect(dec(h!)).toEqual({ alg: 'RS256', typ: 'JWT' })
  131. const claims = dec(c!)
  132. expect(claims.sub).toBe('accounts@meridiandisplay.example')
  133. expect(claims.iss).toBe(SA.email)
  134. expect(claims.scope).toBe(GMAIL_SCOPE)
  135. expect(claims.aud).toBe('https://oauth2.googleapis.com/token')
  136. expect(claims.exp - claims.iat).toBe(3600)
  137. // base64url, not base64: a '+' or '/' in the signature makes Google reject
  138. // the assertion with an error that names none of this.
  139. expect(sig).toMatch(/^[A-Za-z0-9_-]+$/)
  140. })
  141. it('names the causes of unauthorized_client rather than picking one', async () => {
  142. // Rule 1. This is the error that WILL happen during setup, and
  143. // Google says only "unauthorized_client" for all of: client ID not added in
  144. // Admin, scope string mismatched, propagation not finished.
  145. const f = vi.fn(async () => json({ error: 'unauthorized_client' }, 401)) as unknown as typeof fetch
  146. await expect(accessTokenFor('hello@meridiandisplay.example', SA, f)).rejects.toThrow(GmailAuthError)
  147. await expect(accessTokenFor('hello@meridiandisplay.example', SA, f)).rejects.toThrow(/Domain-wide delegation/)
  148. })
  149. it('names the causes of invalid_grant, including a skewed clock', async () => {
  150. const f = vi.fn(async () => json({ error: 'invalid_grant' }, 400)) as unknown as typeof fetch
  151. await expect(accessTokenFor('hello@meridiandisplay.example', SA, f)).rejects.toThrow(/suspended/)
  152. await expect(accessTokenFor('hello@meridiandisplay.example', SA, f)).rejects.toThrow(/clock/)
  153. })
  154. it('accepts a PEM whose newlines were flattened to backslash-n', async () => {
  155. // The key survives a shell, an env var and a secrets UI on its way to the
  156. // Worker, and very often arrives with literal \\n. Rejecting it produces a
  157. // base64 error that names nothing.
  158. const flattened = { ...SA, privateKey: SA.privateKey.replace(/\n/g, '\\n') }
  159. const f = (async () => json({ access_token: 'tok', expires_in: 3599 })) as unknown as typeof fetch
  160. await expect(accessTokenFor('hello@meridiandisplay.example', flattened, f)).resolves.toMatchObject({
  161. accessToken: 'tok',
  162. })
  163. })
  164. it('reports an unreadable key as a key problem, not a network one', async () => {
  165. const bad = { ...SA, privateKey: '-----BEGIN PRIVATE KEY-----\nnot base64!!\n-----END PRIVATE KEY-----' }
  166. const f = (async () => json({ access_token: 'x' })) as unknown as typeof fetch
  167. await expect(accessTokenFor('hello@meridiandisplay.example', bad, f)).rejects.toThrow(/private key could not be read/)
  168. })
  169. it('treats a 200 with no access_token as a failure', async () => {
  170. // Returning '' would surface as a 401 on the next call and send somebody
  171. // debugging the wrong request.
  172. const f = vi.fn(async () => json({ expires_in: 3599 })) as unknown as typeof fetch
  173. await expect(accessTokenFor('hello@meridiandisplay.example', CREDS, f)).rejects.toThrow(/no access_token/)
  174. })
  175. })
  176. describe('cleanSnippet', () => {
  177. it('decodes the entities Gmail encodes snippets with', () => {
  178. expect(cleanSnippet('Tom &amp; Jerry&#39;s &quot;sale&quot;')).toBe('Tom & Jerry\'s "sale"')
  179. })
  180. it('strips the zero-width padding marketing mail games previews with', () => {
  181. expect(cleanSnippet('Real text' + '\u200c\u00ad\u200b\ufeff'.repeat(80))).toBe('Real text')
  182. })
  183. it('caps at 140 with an ellipsis, not a mid-word chop artifact', () => {
  184. const out = cleanSnippet('word '.repeat(60))
  185. expect(out.length).toBeLessThanOrEqual(140)
  186. expect(out.endsWith('…')).toBe(true)
  187. })
  188. it('is empty for an absent snippet, never undefined', () => {
  189. expect(cleanSnippet(undefined)).toBe('')
  190. })
  191. })
  192. describe('readMailbox', () => {
  193. /**
  194. * Gmail's batch endpoint returns multipart/mixed, so the fake has to speak it.
  195. * That is deliberate rather than a nuisance: it means these tests exercise the
  196. * real parser instead of a mock of it, which is where a transport change like
  197. * this one would otherwise hide its bugs.
  198. */
  199. const batchResponse = (msgs: (Record<string, unknown> | undefined)[], boundary = 'testbatch') => {
  200. const parts = msgs
  201. .map(
  202. (m) =>
  203. `--${boundary}\r\nContent-Type: application/http\r\n\r\n` +
  204. (m
  205. ? `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(m)}\r\n`
  206. : `HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ error: { message: 'boom' } })}\r\n`),
  207. )
  208. .join('')
  209. return new Response(`${parts}--${boundary}--\r\n`, {
  210. headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
  211. })
  212. }
  213. /** The ids a batch body asked for, in order. */
  214. const idsIn = (body: string): string[] =>
  215. [...body.matchAll(/messages\/([^?]+)\?/g)].map((m) => decodeURIComponent(m[1]!))
  216. const okFetch = (messages: Record<string, unknown>[]) =>
  217. (async (url: string | URL, init?: RequestInit) => {
  218. const u = String(url)
  219. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  220. if (u.includes('/batch/')) {
  221. const ids = idsIn(String(init?.body ?? ''))
  222. return batchResponse(ids.map((id) => messages.find((m) => m.id === id)))
  223. }
  224. if (u.includes('/messages?')) {
  225. return json({
  226. messages: messages.map((m) => ({ id: m.id })),
  227. resultSizeEstimate: messages.length,
  228. })
  229. }
  230. return json({}, 404)
  231. }) as unknown as typeof fetch
  232. const msg = (id: string, from: string, subject: string) => ({
  233. id,
  234. threadId: `t-${id}`,
  235. internalDate: '1788210063000',
  236. labelIds: ['INBOX', 'CATEGORY_PROMOTIONS'],
  237. payload: {
  238. headers: [
  239. { name: 'From', value: from },
  240. { name: 'Subject', value: subject },
  241. ],
  242. },
  243. })
  244. it('normalizes a message into metadata and nothing else', async () => {
  245. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  246. paceMs: 0,
  247. fetchImpl: okFetch([msg('m1', 'Upscaler Labs <news@upscaler.vendor.example>', 'What is new this month')]),
  248. })
  249. expect(r.ok).toBe(true)
  250. const m = r.messages[0]!
  251. expect(m.sender).toBe('news@upscaler.vendor.example')
  252. expect(m.subject).toBe('What is new this month')
  253. expect(m.mailbox).toBe('hello@meridiandisplay.example')
  254. // The shape carries a snippet but no body: a one-line preview at rest,
  255. // capped at 140 chars, for Gmail-style rows. Bodies are fetch-on-open only; a `text` or `body` field appearing here still
  256. // has to be justified to seven people.
  257. expect(Object.keys(m).sort()).toEqual(
  258. ['date', 'id', 'labels', 'mailbox', 'sender', 'senderName', 'snippet', 'subject', 'threadId', 'unread'],
  259. )
  260. expect(m.snippet.length).toBeLessThanOrEqual(140)
  261. })
  262. it('never asks Gmail for a body in the first place', async () => {
  263. // The sub-requests live in the batch body. format=metadata plus a header
  264. // allow-list means the body is not merely ignored; it never crosses the
  265. // wire, and seven people's mail never lands in this Worker's memory.
  266. let batchBody = ''
  267. const f = (async (url: string | URL, init?: RequestInit) => {
  268. const u = String(url)
  269. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  270. if (u.includes('/batch/')) {
  271. batchBody = String(init?.body ?? '')
  272. return batchResponse([msg('m1', 'a@b.com', 's')])
  273. }
  274. return json({ messages: [{ id: 'm1' }], resultSizeEstimate: 1 })
  275. }) as unknown as typeof fetch
  276. await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: f, paceMs: 0 })
  277. expect(batchBody).toContain('format=metadata')
  278. expect(batchBody).not.toContain('format=full')
  279. expect(batchBody).toContain('metadataHeaders=From')
  280. })
  281. it('returns a failed read rather than throwing, so one dead token keeps six alive', async () => {
  282. // The day a role mailbox changes hands its token stops working. Losing the
  283. // other six inboxes with it would be the worse outcome, and it is the exact
  284. // scenario this tab was built for.
  285. const f = (async (url: string | URL) =>
  286. String(url).includes('oauth2') ? json({ error: 'invalid_grant' }, 400) : json({})) as unknown as typeof fetch
  287. const r = await readMailbox('accounts@meridiandisplay.example', CREDS, { fetchImpl: f, paceMs: 0 })
  288. expect(r.ok).toBe(false)
  289. expect(r.messages).toEqual([])
  290. expect(r.error).toMatch(/invalid_grant/)
  291. })
  292. it('keeps a mailbox when one message fails, and COUNTS the one it lost', async () => {
  293. // The old per-message loop did `if (!res.ok) continue`, which is the right
  294. // call: one unreadable message is not a failed mailbox. But silently
  295. // returning 1 of 2 makes a truncated read look complete. The skip survives;
  296. // the silence does not.
  297. const f = (async (url: string | URL, init?: RequestInit) => {
  298. const u = String(url)
  299. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  300. if (u.includes('/batch/')) {
  301. const ids = idsIn(String(init?.body ?? ''))
  302. return batchResponse(ids.map((id) => (id === 'm1' ? msg('m1', 'a@b.com', 'fine') : undefined)))
  303. }
  304. return json({ messages: [{ id: 'm1' }, { id: 'm2' }], resultSizeEstimate: 2 })
  305. }) as unknown as typeof fetch
  306. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: f, paceMs: 0 })
  307. expect(r.ok).toBe(true)
  308. expect(r.messages).toHaveLength(1)
  309. expect(r.failedFetches).toBe(1)
  310. })
  311. it('reads the inbox window, not the whole archive', async () => {
  312. const seen: string[] = []
  313. const f = (async (url: string | URL) => {
  314. seen.push(String(url))
  315. const u = String(url)
  316. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  317. return json({ messages: [] })
  318. }) as unknown as typeof fetch
  319. await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: f, paceMs: 0 })
  320. expect(decodeURIComponent(seen.find((u) => u.includes('/messages?'))!)).toContain('in:inbox')
  321. })
  322. })
  323. /**
  324. * The scopes, and how the board learns whether a token can send.
  325. *
  326. * A refresh token carries the scopes it was consented with, so a token minted
  327. * before the send scope was added still reads and cannot send. The board must
  328. * be able to see that rather than infer it: a Reply button
  329. * that fails is worse than no Reply button, and "read-only token", "we could
  330. * not ask" and "the mailbox failed" need three different fixes.
  331. */
  332. describe('scopes', () => {
  333. it('asks for read AND send, space-joined for the OAuth parameter', () => {
  334. expect(GMAIL_SCOPES).toContain('https://www.googleapis.com/auth/gmail.readonly')
  335. expect(GMAIL_SCOPES).toContain(GMAIL_SEND_SCOPE)
  336. expect(GMAIL_SCOPE).toBe(GMAIL_SCOPES.join(' '))
  337. })
  338. it('compares a grant as a SET, because Google returns its own order', () => {
  339. // The old `granted !== requested` string compare fired on perfectly valid
  340. // grants, which is how a real warning stops being read.
  341. const reversed = [...GMAIL_SCOPES].reverse().join(' ')
  342. expect(scopesGranted(reversed).ok).toBe(true)
  343. expect(scopesGranted('https://www.googleapis.com/auth/gmail.readonly')).toEqual({
  344. ok: false,
  345. missing: [GMAIL_SEND_SCOPE],
  346. })
  347. })
  348. it('reports the scopes a token actually carries, or null when Google said nothing', async () => {
  349. const withScope = (async () =>
  350. json({ access_token: 't', expires_in: 3599, scope: GMAIL_SCOPE })) as unknown as typeof fetch
  351. await expect(accessTokenFor('hello@meridiandisplay.example', SA, withScope)).resolves.toMatchObject({
  352. scopes: [...GMAIL_SCOPES],
  353. })
  354. const without = (async () => json({ access_token: 't', expires_in: 3599 })) as unknown as typeof fetch
  355. await expect(accessTokenFor('hello@meridiandisplay.example', SA, without)).resolves.toMatchObject({
  356. scopes: null,
  357. })
  358. })
  359. })
  360. describe('readMailbox reports whether the mailbox can send', () => {
  361. const withScopes = (scope: string | null) =>
  362. (async (url: string | URL) => {
  363. const u = String(url)
  364. if (u.includes('oauth2')) {
  365. return json(scope === null ? { access_token: 't', expires_in: 3599 } : { access_token: 't', expires_in: 3599, scope })
  366. }
  367. if (u.includes('/messages?')) return json({ messages: [] })
  368. return json({})
  369. }) as unknown as typeof fetch
  370. it('true when Google reported gmail.send', async () => {
  371. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: withScopes(GMAIL_SCOPE) })
  372. expect(r.canSend).toBe(true)
  373. })
  374. it('false when Google reported the scopes and send was not among them', async () => {
  375. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  376. paceMs: 0,
  377. fetchImpl: withScopes('https://www.googleapis.com/auth/gmail.readonly'),
  378. })
  379. expect(r.canSend).toBe(false)
  380. })
  381. it('null when Google reported no scopes at all, not false', async () => {
  382. // The prime invariant, applied to a permission: "we do not know" is its own
  383. // state and must not be rendered as "it cannot".
  384. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: withScopes(null) })
  385. expect(r.canSend).toBeNull()
  386. })
  387. it('null on a failed read, because we never got far enough to ask', async () => {
  388. const dead = (async () => json({ error: 'invalid_grant' }, 400)) as unknown as typeof fetch
  389. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { fetchImpl: dead })
  390. expect(r.ok).toBe(false)
  391. expect(r.canSend).toBeNull()
  392. })
  393. })
  394. /**
  395. * The batch transport. One fetch per chunk of messages, rather than one per
  396. * message, keeps Cloudflare subrequests from capping how much of a mailbox the
  397. * board can hold.
  398. *
  399. * What is worth testing is the encoding and the failure accounting, because a
  400. * malformed part is rejected with an error naming nothing, and a dropped part
  401. * would quietly shrink a mailbox.
  402. */
  403. describe('buildBatchBody', () => {
  404. it('closes every part and terminates the body, which Gmail requires', () => {
  405. const body = buildBatchBody(['a', 'b'], 'B')
  406. expect(body.startsWith('--B\r\n')).toBe(true)
  407. expect(body.endsWith('--B--\r\n')).toBe(true)
  408. expect(body.match(/Content-Type: application\/http/g)).toHaveLength(2)
  409. })
  410. it('leaves the blank line after each request line', () => {
  411. // A part without it is rejected as malformed, with an error that names
  412. // nothing useful. Easy to lose in a refactor; impossible to debug from the
  413. // response.
  414. expect(buildBatchBody(['a'], 'B')).toMatch(/GET \/gmail\/v1[^\r]*\r\n\r\n/)
  415. })
  416. it('asks only for metadata, and escapes the id', () => {
  417. const body = buildBatchBody(['a/b?c'], 'B')
  418. expect(body).toContain('format=metadata')
  419. expect(body).not.toContain('format=full')
  420. // An id spliced raw into the path would change the request it belongs to.
  421. expect(body).toContain(encodeURIComponent('a/b?c'))
  422. })
  423. })
  424. describe('parseBatchResponse', () => {
  425. const part = (json: string, status = '200 OK') =>
  426. `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 ${status}\r\nContent-Type: application/json\r\n\r\n${json}\r\n`
  427. it('reads every message out of a multipart body', () => {
  428. const body = part('{"id":"m1"}') + part('{"id":"m2"}') + '--X--\r\n'
  429. const r = parseBatchResponse('multipart/mixed; boundary=X', body)
  430. expect(r.messages.map((m) => m.id)).toEqual(['m1', 'm2'])
  431. expect(r.failed).toBe(0)
  432. })
  433. it('matches by the id in the body, not by arrival order', () => {
  434. // Google documents that parts may come back in any order. Every successful
  435. // body already carries its own id, so the mapping is free and cannot drift.
  436. const body = part('{"id":"m2"}') + part('{"id":"m1"}') + '--X--\r\n'
  437. const r = parseBatchResponse('multipart/mixed; boundary=X', body)
  438. expect(new Set(r.messages.map((m) => m.id))).toEqual(new Set(['m1', 'm2']))
  439. })
  440. it('counts an errored part rather than dropping it', () => {
  441. const body = part('{"id":"m1"}') + part('{"error":{"message":"nope"}}', '404 Not Found') + '--X--\r\n'
  442. const r = parseBatchResponse('multipart/mixed; boundary=X', body)
  443. expect(r.messages).toHaveLength(1)
  444. expect(r.failed).toBe(1)
  445. })
  446. it('counts an unparseable part rather than dropping it', () => {
  447. const body = part('{"id":"m1"}') + '--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n{not json\r\n' + '--X--\r\n'
  448. const r = parseBatchResponse('multipart/mixed; boundary=X', body)
  449. expect(r.messages).toHaveLength(1)
  450. expect(r.failed).toBe(1)
  451. })
  452. it('handles a quoted boundary', () => {
  453. const body = part('{"id":"m1"}') + '--X--\r\n'
  454. expect(parseBatchResponse('multipart/mixed; boundary="X"', body).messages).toHaveLength(1)
  455. })
  456. it('invents nothing when the response is not multipart at all', () => {
  457. // An error page or a shape change. Returning a message here would be worse
  458. // than returning none.
  459. const r = parseBatchResponse('text/html', '<html>500</html>')
  460. expect(r.messages).toEqual([])
  461. })
  462. })
  463. describe('readMailbox pages the list', () => {
  464. const page = (ids: string[], next?: string, estimate = 0) =>
  465. json({ messages: ids.map((id) => ({ id })), nextPageToken: next, resultSizeEstimate: estimate })
  466. const paged = (pages: { ids: string[]; next?: string }[], estimate: number) =>
  467. (async (url: string | URL, init?: RequestInit) => {
  468. const u = String(url)
  469. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  470. if (u.includes('/batch/')) {
  471. const ids = [...String(init?.body ?? '').matchAll(/messages\/([^?]+)\?/g)].map((m) => m[1]!)
  472. const parts = ids
  473. .map((id) => `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n${JSON.stringify({ id, threadId: 't', internalDate: '1788210063000', labelIds: ['INBOX'], payload: { headers: [] } })}\r\n`)
  474. .join('')
  475. return new Response(`${parts}--X--\r\n`, { headers: { 'content-type': 'multipart/mixed; boundary=X' } })
  476. }
  477. const token = new URL(u).searchParams.get('pageToken')
  478. const i = token ? pages.findIndex((p) => p.next === token) + 1 : 0
  479. const pg = pages[Math.max(0, i)]!
  480. return page(pg.ids, pg.next, estimate)
  481. }) as unknown as typeof fetch
  482. it('follows nextPageToken past the first page', async () => {
  483. // One list call would stop at whatever the first page held.
  484. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  485. paceMs: 0,
  486. maxMessages: 500,
  487. fetchImpl: paged([{ ids: ['a', 'b'], next: 'p2' }, { ids: ['c'] }], 3),
  488. })
  489. expect(r.messages).toHaveLength(3)
  490. expect(r.truncated).toBe(false)
  491. })
  492. it('stops FETCHING at maxMessages, but still counts the whole window', async () => {
  493. // The list is walked to the end because ids are cheap; only the fetch is
  494. // capped. That is what makes windowTotal a count rather than a guess.
  495. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  496. paceMs: 0,
  497. maxMessages: 2,
  498. fetchImpl: paged([{ ids: ['a', 'b'], next: 'p2' }, { ids: ['c'] }], 9),
  499. })
  500. expect(r.messages).toHaveLength(2)
  501. expect(r.truncated).toBe(true)
  502. expect(r.windowTotal).toBe(3)
  503. })
  504. it('counts the window rather than trusting resultSizeEstimate', async () => {
  505. // Gmail's resultSizeEstimate is capped near the requested page size (about
  506. // 501 at maxResults=500 for any larger window), a number that looks
  507. // authoritative and is not. The fake below returns a deliberately wrong
  508. // estimate; the counted answer must ignore it.
  509. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  510. paceMs: 0,
  511. maxMessages: 500,
  512. fetchImpl: paged([{ ids: ['a', 'b', 'c'] }], 99999),
  513. })
  514. expect(r.windowTotal).toBe(3)
  515. expect(r.truncated).toBe(false)
  516. })
  517. })
  518. /**
  519. * Gmail refusing part of a chunk.
  520. *
  521. * `messages.get` costs 5 quota units against a 250-per-second-per-user ceiling.
  522. * A chunk of 100 (Gmail's documented maximum) spends 500 at once, twice the
  523. * limit, and the overflow is refused: about a third of every large mailbox.
  524. * The documented maximum is a limit on the request, not permission to spend
  525. * the quota. Refused parts must be retried and, if still refused, counted.
  526. */
  527. describe('batching survives Gmail refusing part of a chunk', () => {
  528. const okMsg = (id: string) =>
  529. JSON.stringify({ id, threadId: 't', internalDate: '1788210063000', labelIds: ['INBOX'], payload: { headers: [] } })
  530. /** Refuses the first `refuseFirst` ids it is ever asked for, then relents. */
  531. const flaky = (ids: string[], refuseFirst: number) => {
  532. const refused = new Set(ids.slice(0, refuseFirst))
  533. return (async (url: string | URL, init?: RequestInit) => {
  534. const u = String(url)
  535. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  536. if (u.includes('/batch/')) {
  537. const asked = [...String(init?.body ?? '').matchAll(/messages\/([^?]+)\?/g)].map((m) => m[1]!)
  538. const parts = asked
  539. .map((id) => {
  540. if (refused.has(id)) {
  541. refused.delete(id) // relent on the retry, like a rate limit does
  542. return `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 429 Too Many Requests\r\n\r\n${JSON.stringify({ error: { message: 'rateLimitExceeded' } })}\r\n`
  543. }
  544. return `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n${okMsg(id)}\r\n`
  545. })
  546. .join('')
  547. return new Response(`${parts}--X--\r\n`, { headers: { 'content-type': 'multipart/mixed; boundary=X' } })
  548. }
  549. return json({ messages: ids.map((id) => ({ id })) })
  550. }) as unknown as typeof fetch
  551. }
  552. it('re-asks for refused messages and gets them, losing none', async () => {
  553. const ids = Array.from({ length: 30 }, (_, i) => `m${i}`)
  554. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  555. paceMs: 0,
  556. maxMessages: 100,
  557. fetchImpl: flaky(ids, 12),
  558. })
  559. expect(r.messages).toHaveLength(30)
  560. expect(r.failedFetches).toBe(0)
  561. })
  562. it('reports what is still missing after the retries rather than shrinking quietly', async () => {
  563. // A mailbox that returns 18 of 30 and says nothing looks exactly like a
  564. // mailbox with 18 messages in it.
  565. const ids = Array.from({ length: 30 }, (_, i) => `m${i}`)
  566. const alwaysRefuse = (async (url: string | URL, init?: RequestInit) => {
  567. const u = String(url)
  568. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  569. if (u.includes('/batch/')) {
  570. const asked = [...String(init?.body ?? '').matchAll(/messages\/([^?]+)\?/g)].map((m) => m[1]!)
  571. const parts = asked
  572. .map((id) =>
  573. Number(id.slice(1)) < 10
  574. ? `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n${okMsg(id)}\r\n`
  575. : `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 429\r\n\r\n${JSON.stringify({ error: {} })}\r\n`,
  576. )
  577. .join('')
  578. return new Response(`${parts}--X--\r\n`, { headers: { 'content-type': 'multipart/mixed; boundary=X' } })
  579. }
  580. return json({ messages: ids.map((id) => ({ id })) })
  581. }) as unknown as typeof fetch
  582. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { paceMs: 0, maxMessages: 100, fetchImpl: alwaysRefuse })
  583. expect(r.messages).toHaveLength(10)
  584. expect(r.failedFetches).toBe(20)
  585. expect(r.ok).toBe(true) // still a usable mailbox, just an incomplete one
  586. })
  587. it('survives a whole chunk being refused at the HTTP level', async () => {
  588. const ids = Array.from({ length: 5 }, (_, i) => `m${i}`)
  589. let calls = 0
  590. const f = (async (url: string | URL) => {
  591. const u = String(url)
  592. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  593. if (u.includes('/batch/')) {
  594. calls++
  595. return calls === 1 ? new Response('nope', { status: 503 }) : new Response(
  596. ids.map((id) => `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n${okMsg(id)}\r\n`).join('') + '--X--\r\n',
  597. { headers: { 'content-type': 'multipart/mixed; boundary=X' } },
  598. )
  599. }
  600. return json({ messages: ids.map((id) => ({ id })) })
  601. }) as unknown as typeof fetch
  602. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { paceMs: 0, maxMessages: 100, fetchImpl: f })
  603. expect(r.messages).toHaveLength(5)
  604. expect(r.failedFetches).toBe(0)
  605. })
  606. })
  607. /**
  608. * The quota is per minute, so a short backoff does nothing.
  609. *
  610. * Gmail says so in its own words: "Quota exceeded for quota metric 'Total
  611. * Query Cost' and limit 'Units per MINUTE per user'". Retries a few seconds
  612. * apart land inside the same exhausted minute and are refused for the same
  613. * reason, which is indistinguishable from a permanent failure unless you read
  614. * the error. So the error text is kept.
  615. */
  616. describe('a refused batch reports Gmail\'s own reason', () => {
  617. /** Every part refused with the real quota error Gmail returns. */
  618. const quotaRefusal = (ids: string[]) =>
  619. (async (url: string | URL, init?: RequestInit) => {
  620. const u = String(url)
  621. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  622. if (u.includes('/batch/')) {
  623. const asked = [...String(init?.body ?? '').matchAll(/messages\/([^?]+)\?/g)].map((m) => m[1]!)
  624. const parts = asked
  625. .map(
  626. () =>
  627. `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 403 Forbidden\r\n\r\n${JSON.stringify({
  628. error: {
  629. code: 403,
  630. status: 'PERMISSION_DENIED',
  631. message:
  632. "Quota exceeded for quota metric 'Total Query Cost' and limit 'Units per minute per user'",
  633. },
  634. })}\r\n`,
  635. )
  636. .join('')
  637. return new Response(`${parts}--X--\r\n`, { headers: { 'content-type': 'multipart/mixed; boundary=X' } })
  638. }
  639. return json({ messages: ids.map((id) => ({ id })) })
  640. }) as unknown as typeof fetch
  641. it('carries the quota message, not just a count', async () => {
  642. // An identical number of failures on every run cannot say why; the message
  643. // can. "Units per MINUTE per user" is the difference between lengthening a
  644. // retry delay and rewriting the fetcher.
  645. const ids = ['m1', 'm2']
  646. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  647. paceMs: 0,
  648. maxMessages: 10,
  649. fetchImpl: quotaRefusal(ids),
  650. })
  651. expect(r.failedFetches).toBe(2)
  652. expect(r.failureSample.join(' ')).toContain('Units per minute per user')
  653. expect(r.failureSample.join(' ')).toContain('403')
  654. })
  655. it('deduplicates and caps the sample, so it is a diagnosis and not a wall', async () => {
  656. // Every refused part in a chunk carries the identical quota text. Three
  657. // distinct reasons is useful; two hundred copies of one is noise that
  658. // hides any second reason underneath it.
  659. const ids = Array.from({ length: 60 }, (_, i) => `m${i}`)
  660. const r = await readMailbox('hello@meridiandisplay.example', CREDS, {
  661. paceMs: 0,
  662. maxMessages: 100,
  663. fetchImpl: quotaRefusal(ids),
  664. })
  665. expect(r.failedFetches).toBe(60)
  666. expect(r.failureSample.length).toBeLessThanOrEqual(3)
  667. expect(new Set(r.failureSample).size).toBe(r.failureSample.length)
  668. })
  669. it('reports nothing when nothing failed', async () => {
  670. // An empty sample beside a zero count; never a stale reason from a prior
  671. // pass, which would send someone chasing a limit they are not hitting.
  672. const clean = (async (url: string | URL, init?: RequestInit) => {
  673. const u = String(url)
  674. if (u.includes('oauth2')) return json({ access_token: 'tok', expires_in: 3599 })
  675. if (u.includes('/batch/')) {
  676. const asked = [...String(init?.body ?? '').matchAll(/messages\/([^?]+)\?/g)].map((m) => m[1]!)
  677. const parts = asked
  678. .map(
  679. (id) =>
  680. `--X\r\nContent-Type: application/http\r\n\r\nHTTP/1.1 200 OK\r\n\r\n${JSON.stringify({ id, threadId: 't', internalDate: '1788210063000', labelIds: ['INBOX'], payload: { headers: [] } })}\r\n`,
  681. )
  682. .join('')
  683. return new Response(`${parts}--X--\r\n`, { headers: { 'content-type': 'multipart/mixed; boundary=X' } })
  684. }
  685. return json({ messages: [{ id: 'm1' }] })
  686. }) as unknown as typeof fetch
  687. const r = await readMailbox('hello@meridiandisplay.example', CREDS, { paceMs: 0, fetchImpl: clean })
  688. expect(r.failedFetches).toBe(0)
  689. expect(r.failureSample).toEqual([])
  690. })
  691. })