* 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>
150 lines
5.1 KiB
TypeScript
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()
|
|
})
|
|
})
|