fix(cloud-backup): mark dead Google tokens needs-reauth and surface reconnect in the UI (#970)
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b06d73c23e
commit
2be104ba34
@@ -4,6 +4,7 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
refreshAccessToken,
|
||||
getOAuthEnv,
|
||||
GoogleTokenRefreshError,
|
||||
} from '../google-oauth'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -100,4 +101,33 @@ describe('refreshAccessToken', () => {
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/reports/full-archive-export', () => ({
|
||||
estimateArchiveSize: vi.fn(),
|
||||
generateFullArchive: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../google-oauth', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../google-oauth')>()
|
||||
return {
|
||||
...actual,
|
||||
getOAuthEnv: vi.fn().mockReturnValue({
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
redirectUri: 'https://app.test/callback',
|
||||
}),
|
||||
refreshAccessToken: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../google-drive', () => ({
|
||||
ensureFolder: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../crypto', () => ({
|
||||
decryptToken: vi.fn().mockReturnValue('plain-refresh-token'),
|
||||
}))
|
||||
|
||||
import { performSync, CONNECTION_KEY, LAST_SYNC_KEY } from '../sync'
|
||||
import { GoogleTokenRefreshError, refreshAccessToken } from '../google-oauth'
|
||||
import { ensureFolder, uploadFile } from '../google-drive'
|
||||
import {
|
||||
estimateArchiveSize,
|
||||
generateFullArchive,
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import type { GoogleDriveConnection } from '../../types'
|
||||
|
||||
const mockRefreshAccessToken = vi.mocked(refreshAccessToken)
|
||||
const mockEnsureFolder = vi.mocked(ensureFolder)
|
||||
const mockUploadFile = vi.mocked(uploadFile)
|
||||
const mockEstimateArchiveSize = vi.mocked(estimateArchiveSize)
|
||||
const mockGenerateFullArchive = vi.mocked(generateFullArchive)
|
||||
|
||||
function makeConnection(
|
||||
overrides: Partial<GoogleDriveConnection> = {}
|
||||
): GoogleDriveConnection {
|
||||
return {
|
||||
refresh_token_encrypted: 'encrypted-token',
|
||||
account_email: 'user@example.com',
|
||||
connected_at: '2026-01-01T00:00:00.000Z',
|
||||
root_folder_id: 'root-1',
|
||||
company_folder_id: 'company-1',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal supabase stub covering what performSync touches:
|
||||
* - extension_data select ... maybeSingle() (connection load)
|
||||
* - extension_data upsert (connection/last-sync save)
|
||||
* - company_settings select ... maybeSingle() (folder label)
|
||||
*/
|
||||
function makeSupabase(connection: GoogleDriveConnection | null) {
|
||||
const upsert = vi.fn().mockResolvedValue({ error: null })
|
||||
const from = vi.fn().mockImplementation((table: string) => {
|
||||
const chain: any = {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn().mockReturnThis(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data:
|
||||
table === 'extension_data'
|
||||
? connection
|
||||
? { value: connection }
|
||||
: null
|
||||
: { company_name: 'Testbolag AB', org_number: '556000-0000' },
|
||||
}),
|
||||
upsert,
|
||||
}
|
||||
return chain
|
||||
})
|
||||
return { supabase: { from } as any, upsert }
|
||||
}
|
||||
|
||||
function syncParams(supabase: any) {
|
||||
return {
|
||||
supabase,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
origin: 'https://app.test',
|
||||
includeDocuments: true,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockEstimateArchiveSize.mockResolvedValue({
|
||||
total_bytes: 1_000,
|
||||
document_bytes: 100,
|
||||
} as any)
|
||||
})
|
||||
|
||||
describe('performSync needs_reauth handling', () => {
|
||||
it('flags the connection needs_reauth when Google returns 400 invalid_grant', async () => {
|
||||
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||
new GoogleTokenRefreshError(
|
||||
400,
|
||||
'{"error":"invalid_grant","error_description":"Token has been expired or revoked."}'
|
||||
)
|
||||
)
|
||||
|
||||
const result = await performSync(syncParams(supabase))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'needs_reauth' })
|
||||
expect(upsert).toHaveBeenCalledTimes(1)
|
||||
const [payload] = upsert.mock.calls[0]
|
||||
expect(payload.key).toBe(CONNECTION_KEY)
|
||||
expect(payload.value.status).toBe('needs_reauth')
|
||||
expect(payload.value.needs_reauth_at).toEqual(expect.any(String))
|
||||
// Token, email etc. stay intact so the UI can still show the account.
|
||||
expect(payload.value.account_email).toBe('user@example.com')
|
||||
expect(mockGenerateFullArchive).not.toHaveBeenCalled()
|
||||
expect(mockUploadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rethrows transient refresh failures (5xx) without flagging', async () => {
|
||||
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||
new GoogleTokenRefreshError(500, 'Internal Server Error')
|
||||
)
|
||||
|
||||
await expect(performSync(syncParams(supabase))).rejects.toThrow(/500/)
|
||||
expect(upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rethrows a 400 that is not invalid_grant without flagging', async () => {
|
||||
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||
mockRefreshAccessToken.mockRejectedValueOnce(
|
||||
new GoogleTokenRefreshError(400, '{"error":"invalid_request"}')
|
||||
)
|
||||
|
||||
await expect(performSync(syncParams(supabase))).rejects.toThrow(/400/)
|
||||
expect(upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rethrows non-refresh errors untouched', async () => {
|
||||
const { supabase, upsert } = makeSupabase(makeConnection())
|
||||
mockRefreshAccessToken.mockRejectedValueOnce(new Error('network down'))
|
||||
|
||||
await expect(performSync(syncParams(supabase))).rejects.toThrow('network down')
|
||||
expect(upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a stale needs_reauth flag after a successful refresh', async () => {
|
||||
const { supabase, upsert } = makeSupabase(
|
||||
makeConnection({
|
||||
status: 'needs_reauth',
|
||||
needs_reauth_at: '2026-07-01T03:00:00.000Z',
|
||||
})
|
||||
)
|
||||
mockRefreshAccessToken.mockResolvedValueOnce({
|
||||
access_token: 'fresh-token',
|
||||
expires_in: 3600,
|
||||
})
|
||||
mockGenerateFullArchive.mockResolvedValueOnce(new ArrayBuffer(8))
|
||||
mockUploadFile.mockResolvedValueOnce({
|
||||
id: 'file-1',
|
||||
name: 'arkiv.zip',
|
||||
size_bytes: 8,
|
||||
web_view_link: 'https://drive.google.com/file/d/file-1/view',
|
||||
} as any)
|
||||
|
||||
const result = await performSync(syncParams(supabase))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// First upsert rewrites the connection with the flag cleared.
|
||||
const connectionSave = upsert.mock.calls.find(
|
||||
([payload]) => payload.key === CONNECTION_KEY
|
||||
)
|
||||
expect(connectionSave).toBeDefined()
|
||||
expect(connectionSave![0].value.status).toBe('active')
|
||||
// Last-sync state is still persisted as usual.
|
||||
const lastSyncSave = upsert.mock.calls.find(
|
||||
([payload]) => payload.key === LAST_SYNC_KEY
|
||||
)
|
||||
expect(lastSyncSave).toBeDefined()
|
||||
})
|
||||
|
||||
it('still returns not_connected when no connection exists', async () => {
|
||||
const { supabase } = makeSupabase(null)
|
||||
|
||||
const result = await performSync(syncParams(supabase))
|
||||
|
||||
expect(result).toMatchObject({ ok: false, reason: 'not_connected' })
|
||||
expect(mockRefreshAccessToken).not.toHaveBeenCalled()
|
||||
expect(mockEnsureFolder).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -99,6 +99,32 @@ export interface AccessTokenResult {
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when Google's token endpoint rejects a refresh attempt. Carries the
|
||||
* HTTP status and raw response body so callers can distinguish a permanently
|
||||
* dead refresh token (400 invalid_grant) from transient failures.
|
||||
*/
|
||||
export class GoogleTokenRefreshError extends Error {
|
||||
readonly status: number
|
||||
readonly body: string
|
||||
|
||||
constructor(status: number, body: string) {
|
||||
super(`Google token refresh failed: ${status} ${body}`)
|
||||
this.name = 'GoogleTokenRefreshError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/**
|
||||
* True when Google reports the refresh token itself is dead (revoked,
|
||||
* expired, or the grant was invalidated). Retrying will never succeed;
|
||||
* the user must re-consent.
|
||||
*/
|
||||
get isInvalidGrant(): boolean {
|
||||
return this.status === 400 && this.body.includes('invalid_grant')
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
env: OAuthEnv,
|
||||
refreshToken: string
|
||||
@@ -120,7 +146,7 @@ export async function refreshAccessToken(
|
||||
)
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`Google token refresh failed: ${res.status} ${errText}`)
|
||||
throw new GoogleTokenRefreshError(res.status, errText)
|
||||
}
|
||||
return (await res.json()) as AccessTokenResult
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
getOAuthEnv,
|
||||
refreshAccessToken,
|
||||
GoogleTokenRefreshError,
|
||||
} from './google-oauth'
|
||||
import { ensureFolder, uploadFile } from './google-drive'
|
||||
import { decryptToken } from './crypto'
|
||||
@@ -19,6 +20,7 @@ export const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
|
||||
export type SyncFailureReason =
|
||||
| 'not_connected'
|
||||
| 'needs_reauth'
|
||||
| 'archive_too_large'
|
||||
| 'upload_failed'
|
||||
| 'internal'
|
||||
@@ -81,7 +83,30 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
|
||||
const env = getOAuthEnv(origin)
|
||||
const refreshToken = decryptToken(connection.refresh_token_encrypted)
|
||||
const { access_token: accessToken } = await refreshAccessToken(env, refreshToken)
|
||||
let accessToken: string
|
||||
try {
|
||||
const refreshed = await refreshAccessToken(env, refreshToken)
|
||||
accessToken = refreshed.access_token
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleTokenRefreshError && err.isInvalidGrant) {
|
||||
// The refresh token is permanently dead (revoked or expired). Flag the
|
||||
// connection so the cron stops retrying it and the UI can ask the user
|
||||
// to reconnect. Other failures (network, 5xx) stay throwing: they are
|
||||
// transient and worth retrying.
|
||||
const flagged: GoogleDriveConnection = {
|
||||
...connection,
|
||||
status: 'needs_reauth',
|
||||
needs_reauth_at: new Date().toISOString(),
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, flagged)
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'needs_reauth',
|
||||
message: 'Google Drive authorization expired; reconnect required',
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
let rootFolderId = connection.root_folder_id
|
||||
let companyFolderId = connection.company_folder_id
|
||||
@@ -96,12 +121,16 @@ export async function performSync(params: PerformSyncParams): Promise<PerformSyn
|
||||
}
|
||||
if (
|
||||
rootFolderId !== connection.root_folder_id ||
|
||||
companyFolderId !== connection.company_folder_id
|
||||
companyFolderId !== connection.company_folder_id ||
|
||||
connection.status === 'needs_reauth'
|
||||
) {
|
||||
// A successful refresh also clears a stale needs_reauth flag.
|
||||
await saveExtensionData(supabase, companyId, userId, CONNECTION_KEY, {
|
||||
...connection,
|
||||
root_folder_id: rootFolderId,
|
||||
company_folder_id: companyFolderId,
|
||||
status: 'active',
|
||||
needs_reauth_at: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user