Files
accounted/extensions/general/mail/__tests__/connect-gate.test.ts
T
c0eda46354 feat(mail): withhold new Gmail consents on hosted unless the company is allowlisted (#2320)
Every Gmail consent shows "Google hasn't verified this app" until the
restricted-scope review closes, and a prospect bounced on it today. Jakob's
call: remove the connector in the meantime rather than explain the screen.

New consents are gated by GOOGLE_MAIL_CONNECT_COMPANY_IDS on hosted: unset
means nobody (the default from this deploy on), `*` means everybody (set once
Google approves), a comma list means those companies (the reviewer's demo
company, the company the video is recorded in). Enforced in /oauth/start
(403 connect_disabled) and mirrored as connectEnabled on /connections, so the
settings page drops its connect button and the inbox start card falls back to
plain upload. Existing mailboxes stay listed, keep being searched and can be
disconnected. Self-hosted installs run their own Google app and are never
gated.


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 17:51:45 +02:00

114 lines
4.4 KiB
TypeScript

/**
* New Gmail consents are withheld on hosted while Google's scope review is
* open, unless the company is allowlisted. Existing mailboxes are unaffected.
*/
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'
vi.mock('@/lib/mail-search/service', () => ({ registerMailSearchService: vi.fn() }))
vi.mock('../lib/search-service', () => ({ GmailSearchService: class GmailSearchService {} }))
vi.mock('@/lib/auth/api-keys', () => ({ createServiceClientNoCookies: vi.fn(() => ({})) }))
vi.mock('../lib/google-oauth', () => ({
buildAuthorizationUrl: vi.fn(() => 'https://accounts.google.com/o/oauth2/v2/auth?x=1'),
exchangeCodeForTokens: vi.fn(),
getGoogleOAuthEnv: vi.fn(() => ({})),
isGoogleMailConfigured: vi.fn(() => true),
}))
vi.mock('../lib/connections', () => ({
disconnect: vi.fn(),
listConnections: vi.fn(),
saveConnection: vi.fn(),
}))
vi.mock('../lib/gmail-client', () => ({ getMailboxAddress: vi.fn() }))
vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), createServiceClient: vi.fn() }))
import { mailExtension } from '../index'
import { isMailConnectEnabled } from '../lib/connect-gate'
import { listConnections } from '../lib/connections'
const COMPANY = 'company-1'
const route = (method: 'GET' | 'POST', path: string) =>
mailExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
const ctx = {
userId: 'user-1',
companyId: COMPANY,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
} as unknown as Parameters<ReturnType<typeof route>['handler']>[1]
beforeEach(() => {
vi.clearAllMocks()
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('MAIL_TOKEN_ENCRYPTION_KEY', '00'.repeat(32))
;(listConnections as Mock).mockResolvedValue([])
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('isMailConnectEnabled', () => {
it('withholds every company when the allowlist is unset', () => {
expect(isMailConnectEnabled(COMPANY, undefined)).toBe(false)
expect(isMailConnectEnabled(COMPANY, '')).toBe(false)
expect(isMailConnectEnabled(COMPANY, ' , ')).toBe(false)
})
it('opens for everybody on a star, and for listed companies only otherwise', () => {
expect(isMailConnectEnabled(COMPANY, '*')).toBe(true)
expect(isMailConnectEnabled(COMPANY, `other, ${COMPANY} `)).toBe(true)
expect(isMailConnectEnabled(COMPANY, 'other')).toBe(false)
})
it('never gates a self-hosted install, which runs its own Google app', () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
expect(isMailConnectEnabled(COMPANY, undefined)).toBe(true)
})
})
describe('POST /oauth/start', () => {
it('refuses to start a consent for a company that is not allowlisted', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '')
const res = await route('POST', '/oauth/start').handler(
new Request('https://app.example/api/extensions/ext/mail/oauth/start', { method: 'POST' }),
ctx,
)
expect(res.status).toBe(403)
expect(await res.json()).toEqual({ error: 'connect_disabled' })
})
it('starts the consent for an allowlisted company', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', COMPANY)
const res = await route('POST', '/oauth/start').handler(
new Request('https://app.example/api/extensions/ext/mail/oauth/start', { method: 'POST' }),
ctx,
)
expect(res.status).toBe(200)
expect((await res.json()).url).toContain('accounts.google.com')
})
})
describe('GET /connections', () => {
it('still lists existing mailboxes and says whether a new consent may start', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '')
;(listConnections as Mock).mockResolvedValue([{ id: 'c1', provider: 'gmail', status: 'active' }])
const res = await route('GET', '/connections').handler(
new Request('https://app.example/api/extensions/ext/mail/connections'),
ctx,
)
const body = await res.json()
expect(body.data.connections).toHaveLength(1)
expect(body.data.configured).toBe(true)
expect(body.data.connectEnabled).toBe(false)
})
it('reports connectEnabled once the scope is approved and the star is set', async () => {
vi.stubEnv('GOOGLE_MAIL_CONNECT_COMPANY_IDS', '*')
const res = await route('GET', '/connections').handler(
new Request('https://app.example/api/extensions/ext/mail/connections'),
ctx,
)
expect((await res.json()).data.connectEnabled).toBe(true)
})
})