Files
accounted/extensions/general/mail/lib/__tests__/gmail-client.test.ts
T
971952fe19 fix(mail): request gmail.readonly alone, mailbox address via Gmail profile (Google verification) (#2301)
* fix(mail): request gmail.readonly alone and read the mailbox address from Gmail's profile

Google's restricted-scope review (2026-08-31) bounced the Gmail connector on a
"scope discrepancy": the authorization URL asked for `openid email` on top of
gmail.readonly, while the Cloud Console declares gmail.readonly only, and the
review string-matches the two. The extra scopes existed solely to learn the
mailbox address from the id_token. Gmail's users.getProfile returns that
address under gmail.readonly, so the consent request now carries exactly one
scope and the callback reads the address from the profile.

Also adds `app_metadata.mfa_exempt === true` to shouldEnforceMfa. Google's
reviewers log in with credentials we hand them and treat a second factor as an
"authentication blocker"; app_metadata is service-role only, so this is an
operator switch for demo accounts, never a user-reachable setting.

Tests: scope pinned in google-oauth.test.ts, profile read in
gmail-client.test.ts, callback path in oauth-callback.test.ts, flag shape in
mfa.test.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ

* fix(auth): time-box the reviewer MFA exemption instead of a boolean flag

Superagent's P1 on the first shape was fair: a boolean app_metadata.mfa_exempt
relied on someone remembering to clear it. The exemption is now
app_metadata.mfa_exempt_until, an ISO timestamp honoured only while it lies
in the future, so a forgotten flag dies on its own. Anything malformed or
non-string enforces MFA. Still service-role only, still meant for the one
demo account Google's OAuth reviewers log in with.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UD3HsDX8hnJEqpt35azxBJ

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 14:42:21 +02:00

150 lines
5.1 KiB
TypeScript

/**
* Reading a message well enough to know whether it carries an underlag.
*
* The case that matters is the one a provkörning caught: asking Gmail for
* `format=metadata` returns headers and no `payload.parts`, so every message
* looks attachment-free and the hunt can never file anything. These tests pin
* the format and the MIME walk.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { clearMessageCache, getMailboxAddress, getMessageSummary } from '../gmail-client'
const mockFetch = vi.fn()
vi.stubGlobal('fetch', (...args: unknown[]) => mockFetch(...args))
function respond(message: Record<string, unknown>) {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(message),
text: () => Promise.resolve(''),
})
}
const HEADERS = [
{ name: 'Subject', value: 'Faktura-20251070' },
{ name: 'From', value: 'info@tic.io' },
]
beforeEach(() => {
vi.clearAllMocks()
// The cache is keyed by message id, and these tests reuse ids.
clearMessageCache()
})
describe('getMessageSummary', () => {
it('asks for the full message, because metadata omits the parts tree', async () => {
respond({ id: 'm1', payload: { headers: HEADERS } })
await getMessageSummary('token', 'm1', 'conn-1', 'invoice@arcim.io')
const url = String(mockFetch.mock.calls[0][0])
expect(url).toContain('format=full')
// The bug this replaces: metadata returns no payload.parts at all.
expect(url).not.toContain('format=metadata')
})
it('finds a PDF nested inside a forwarded message', async () => {
// Forwarding is how most of these receipts arrive, and it buries the
// attachment two levels down inside a message/rfc822 part.
respond({
id: 'm1',
payload: {
headers: HEADERS,
mimeType: 'multipart/mixed',
parts: [
{ mimeType: 'text/plain', body: { size: 12 } },
{
mimeType: 'message/rfc822',
parts: [
{
mimeType: 'multipart/mixed',
parts: [
{ mimeType: 'text/html', body: { size: 900 } },
{
mimeType: 'application/pdf',
filename: 'faktura.pdf',
body: { attachmentId: 'att-deep', size: 51200 },
},
],
},
],
},
],
},
})
const candidate = await getMessageSummary('token', 'm1', 'conn-1', 'invoice@arcim.io')
expect(candidate.attachmentIds).toEqual(['att-deep'])
expect(candidate.bodyIsReceipt).toBe(false)
})
it('does not mistake an inline logo for an underlag', async () => {
respond({
id: 'm1',
payload: {
headers: HEADERS,
parts: [
{ mimeType: 'image/png', filename: 'logo.png', body: { attachmentId: 'logo', size: 400 } },
],
},
})
const candidate = await getMessageSummary('token', 'm1', 'conn-1', 'invoice@arcim.io')
expect(candidate.attachmentIds).toEqual([])
expect(candidate.bodyIsReceipt).toBe(true)
})
it('reports a genuinely attachment-free mail as a body receipt', async () => {
respond({
id: 'm1',
payload: { headers: HEADERS, mimeType: 'text/html', body: { size: 4000 } },
})
const candidate = await getMessageSummary('token', 'm1', 'conn-1', 'invoice@arcim.io')
expect(candidate.bodyIsReceipt).toBe(true)
expect(candidate.subject).toBe('Faktura-20251070')
})
})
/**
* One receipt mail answers many purchases' queries, so the same message was
* downloaded dozens of times per press. Content never changes, so reading it
* once is both correct and the difference between a press that fits its time
* budget and one that does not.
*/
describe('message reads are not repeated', () => {
it('fetches a given message once per mailbox', async () => {
respond({ id: 'cache-me', payload: { headers: HEADERS } })
await getMessageSummary('token', 'cache-me', 'conn-1', 'invoice@arcim.io')
await getMessageSummary('token', 'cache-me', 'conn-1', 'invoice@arcim.io')
await getMessageSummary('token', 'cache-me', 'conn-1', 'invoice@arcim.io')
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('keeps mailboxes apart, since a hit in one says nothing about the other', async () => {
respond({ id: 'shared', payload: { headers: HEADERS } })
await getMessageSummary('token', 'shared', 'conn-1', 'invoice@arcim.io')
await getMessageSummary('token', 'shared', 'conn-2', 'jakob@arcim.io')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
})
describe('getMailboxAddress', () => {
it('reads the address from the Gmail profile endpoint, which gmail.readonly covers', async () => {
respond({ emailAddress: 'Owner@Example.test', messagesTotal: 12 })
const address = await getMailboxAddress('token')
expect(String(mockFetch.mock.calls[0][0])).toBe(
'https://gmail.googleapis.com/gmail/v1/users/me/profile',
)
expect(address).toBe('Owner@Example.test')
})
it('returns null when the profile carries no address', async () => {
respond({ messagesTotal: 0 })
expect(await getMailboxAddress('token')).toBeNull()
})
})