2be104ba34
Nightly cloud-backup syncs kept retrying Google connections whose refresh token is permanently dead (Google returns 400 invalid_grant; 3 of 12 prod connections are in this state), and the settings card showed the raw English error string while presenting the account as connected. - refreshAccessToken now throws a typed GoogleTokenRefreshError carrying status + body, with an isInvalidGrant discriminator. - performSync catches the invalid_grant case, persists status: 'needs_reauth' (+ needs_reauth_at) on the connection JSON in extension_data (no migration needed), and returns a needs_reauth failure instead of throwing. Transient failures (5xx, network, other 400s) still throw and stay retried. - The nightly cron loads connections for due companies and skips needs_reauth ones (reported as skipped in the summary) instead of retrying the dead token every night. A successful refresh clears a stale flag; reconnecting via OAuth writes a fresh connection. - CloudBackupCard shows a reconnect callout (Swedish-first, sv+en strings) wired to the existing connect action, and replaces the raw error string on the schedule row with a short reconnect notice. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import {
|
|
buildAuthorizationUrl,
|
|
exchangeCodeForTokens,
|
|
refreshAccessToken,
|
|
getOAuthEnv,
|
|
GoogleTokenRefreshError,
|
|
} from '../google-oauth'
|
|
|
|
beforeEach(() => {
|
|
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
|
|
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('getOAuthEnv', () => {
|
|
it('builds redirect URI from origin', () => {
|
|
const env = getOAuthEnv('https://app.example.com')
|
|
expect(env.redirectUri).toBe(
|
|
'https://app.example.com/api/extensions/ext/cloud-backup/oauth/callback'
|
|
)
|
|
expect(env.clientId).toBe('test-client-id')
|
|
})
|
|
|
|
it('throws when env vars missing', () => {
|
|
delete process.env.GOOGLE_CLIENT_ID
|
|
expect(() => getOAuthEnv('http://localhost:3000')).toThrow(/GOOGLE_CLIENT_ID/)
|
|
})
|
|
})
|
|
|
|
describe('buildAuthorizationUrl', () => {
|
|
it('includes scope, offline access, consent prompt, and state', () => {
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const url = buildAuthorizationUrl(env, 'abc123state')
|
|
const parsed = new URL(url)
|
|
expect(parsed.origin + parsed.pathname).toBe(
|
|
'https://accounts.google.com/o/oauth2/v2/auth'
|
|
)
|
|
expect(parsed.searchParams.get('access_type')).toBe('offline')
|
|
expect(parsed.searchParams.get('prompt')).toBe('consent')
|
|
expect(parsed.searchParams.get('state')).toBe('abc123state')
|
|
expect(parsed.searchParams.get('scope')).toContain(
|
|
'https://www.googleapis.com/auth/drive.file'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('exchangeCodeForTokens', () => {
|
|
it('posts form-encoded body and parses token response', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
access_token: 'at',
|
|
refresh_token: 'rt',
|
|
expires_in: 3600,
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const result = await exchangeCodeForTokens(env, 'auth-code')
|
|
|
|
expect(result.refresh_token).toBe('rt')
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
const [url, init] = fetchMock.mock.calls[0]
|
|
expect(url).toBe('https://oauth2.googleapis.com/token')
|
|
expect((init as RequestInit).method).toBe('POST')
|
|
expect(String((init as RequestInit).body)).toContain('grant_type=authorization_code')
|
|
expect(String((init as RequestInit).body)).toContain('code=auth-code')
|
|
})
|
|
|
|
it('throws a clear error when no refresh_token is returned', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ access_token: 'at', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/refresh token/i)
|
|
})
|
|
|
|
it('throws on non-OK response', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response('Bad Request', { status: 400 })
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/400/)
|
|
})
|
|
})
|
|
|
|
describe('refreshAccessToken', () => {
|
|
it('returns a fresh access token', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ access_token: 'new-at', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const result = await refreshAccessToken(env, 'old-refresh')
|
|
expect(result.access_token).toBe('new-at')
|
|
})
|
|
|
|
it('throws GoogleTokenRefreshError carrying status and body on non-OK response', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response('{"error":"invalid_grant"}', { status: 400 })
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const err = await refreshAccessToken(env, 'dead-refresh').catch((e) => e)
|
|
expect(err).toBeInstanceOf(GoogleTokenRefreshError)
|
|
expect(err.status).toBe(400)
|
|
expect(err.body).toContain('invalid_grant')
|
|
expect(err.message).toContain('400')
|
|
})
|
|
})
|
|
|
|
describe('GoogleTokenRefreshError.isInvalidGrant', () => {
|
|
it('is true only for a 400 whose body mentions invalid_grant', () => {
|
|
expect(
|
|
new GoogleTokenRefreshError(400, '{"error":"invalid_grant"}').isInvalidGrant
|
|
).toBe(true)
|
|
expect(
|
|
new GoogleTokenRefreshError(400, '{"error":"invalid_request"}').isInvalidGrant
|
|
).toBe(false)
|
|
expect(
|
|
new GoogleTokenRefreshError(500, '{"error":"invalid_grant"}').isInvalidGrant
|
|
).toBe(false)
|
|
expect(new GoogleTokenRefreshError(503, 'Service Unavailable').isInvalidGrant).toBe(
|
|
false
|
|
)
|
|
})
|
|
})
|