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:
Jakob Wennberg
2026-07-10 11:04:11 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b06d73c23e
commit 2be104ba34
11 changed files with 562 additions and 15 deletions
@@ -7,6 +7,7 @@ vi.mock('@supabase/supabase-js', () => ({
vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({
performSync: vi.fn(),
CONNECTION_KEY: 'google_drive_connection',
SCHEDULE_KEY: 'google_drive_schedule',
saveExtensionData: vi.fn().mockResolvedValue(undefined),
}))
@@ -34,13 +35,44 @@ function makeRequest() {
})
}
function makeSupabaseStub(rows: unknown[], error: unknown = null) {
const chain = {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
then: (resolve: (v: unknown) => void) => resolve({ data: rows, error }),
}
return { from: vi.fn().mockReturnValue(chain) } as any
/**
* The route issues two queries against extension_data: schedules
* (key = google_drive_schedule) and connections (key = google_drive_connection,
* with an .in() filter). Route rows to the right result by the `key` eq filter.
*/
function makeSupabaseStub(
scheduleRows: unknown[],
options: {
scheduleError?: unknown
connectionRows?: unknown[]
connectionError?: unknown
} = {}
) {
const from = vi.fn().mockImplementation(() => {
let key: string | null = null
const chain: any = {
select: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
eq: vi.fn().mockImplementation((column: string, value: string) => {
if (column === 'key') key = value
return chain
}),
then: (resolve: (v: unknown) => void) => {
if (key === 'google_drive_connection') {
return resolve({
data: options.connectionRows ?? [],
error: options.connectionError ?? null,
})
}
return resolve({
data: scheduleRows,
error: options.scheduleError ?? null,
})
},
}
return chain
})
return { from } as any
}
describe('cloud-backup auto-sync cron', () => {
@@ -224,4 +256,124 @@ describe('cloud-backup auto-sync cron', () => {
expect((value as any).last_auto_sync_status).toBe('error')
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
})
it('skips connections flagged needs_reauth without syncing or touching the schedule', async () => {
mockCreateClient.mockReturnValueOnce(
makeSupabaseStub(
[
{
company_id: 'c-1',
user_id: 'u-1',
value: {
enabled: true,
hour_utc: new Date().getUTCHours(),
last_auto_sync_at: null,
},
},
],
{
connectionRows: [
{ company_id: 'c-1', value: { status: 'needs_reauth' } },
],
}
)
)
const res = await GET(makeRequest())
const body = await res.json()
expect(mockPerformSync).not.toHaveBeenCalled()
expect(mockSaveExtensionData).not.toHaveBeenCalled()
expect(body.skipped).toBe(1)
expect(body.successes).toBe(0)
expect(body.errors).toBe(0)
expect(body.results).toEqual([
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
])
})
it('only skips the flagged company when others are due', async () => {
mockCreateClient.mockReturnValueOnce(
makeSupabaseStub(
[
{
company_id: 'c-dead',
user_id: 'u-1',
value: {
enabled: true,
hour_utc: new Date().getUTCHours(),
last_auto_sync_at: null,
},
},
{
company_id: 'c-live',
user_id: 'u-2',
value: {
enabled: true,
hour_utc: new Date().getUTCHours(),
last_auto_sync_at: null,
},
},
],
{
connectionRows: [
{ company_id: 'c-dead', value: { status: 'needs_reauth' } },
{ company_id: 'c-live', value: { status: 'active' } },
],
}
)
)
mockPerformSync.mockResolvedValueOnce({
ok: true,
lastSync: {
at: '2026-07-10T03:00:00Z',
file_id: 'f-1',
file_name: 'arkiv.zip',
file_size_bytes: 1000,
folder_id: 'folder-1',
},
webViewLink: 'https://drive.google.com/file/d/f-1/view',
})
const res = await GET(makeRequest())
const body = await res.json()
expect(mockPerformSync).toHaveBeenCalledTimes(1)
expect(mockPerformSync).toHaveBeenCalledWith(
expect.objectContaining({ companyId: 'c-live' })
)
expect(body.skipped).toBe(1)
expect(body.successes).toBe(1)
})
it('fails open and attempts the sync when the connection lookup errors', async () => {
mockCreateClient.mockReturnValueOnce(
makeSupabaseStub(
[
{
company_id: 'c-1',
user_id: 'u-1',
value: {
enabled: true,
hour_utc: new Date().getUTCHours(),
last_auto_sync_at: null,
},
},
],
{ connectionError: { message: 'connection query failed' } }
)
)
mockPerformSync.mockResolvedValueOnce({
ok: false,
reason: 'needs_reauth',
message: 'Google Drive authorization expired; reconnect required',
})
const res = await GET(makeRequest())
const body = await res.json()
// performSync is still attempted (it re-flags dead tokens itself).
expect(mockPerformSync).toHaveBeenCalledTimes(1)
expect(body.errors).toBe(1)
})
})
@@ -4,10 +4,14 @@ import { withCronContext } from '@/lib/api/with-cron-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import {
performSync,
CONNECTION_KEY,
SCHEDULE_KEY,
saveExtensionData,
} from '@/extensions/general/cloud-backup/lib/sync'
import type { GoogleDriveSchedule } from '@/extensions/general/cloud-backup/types'
import type {
GoogleDriveConnection,
GoogleDriveSchedule,
} from '@/extensions/general/cloud-backup/types'
/**
* GET /api/extensions/cloud-backup/auto-sync/cron
@@ -73,6 +77,35 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
})
}
// Connections flagged needs_reauth carry a permanently dead refresh token
// (Google returned 400 invalid_grant): skip them instead of retrying every
// night. They stay visible in the UI until the user reconnects.
const { data: connectionRows, error: connectionError } = await supabase
.from('extension_data')
.select('company_id, value')
.eq('extension_id', 'cloud-backup')
.eq('key', CONNECTION_KEY)
.in(
'company_id',
candidates.map((r) => r.company_id as string)
)
if (connectionError) {
// Fail open: without connection data we cannot tell who needs reauth,
// so fall back to attempting everyone (performSync re-flags dead tokens).
ctx.log.warn('failed to fetch connections for reauth check', {
message: connectionError.message,
})
}
const needsReauthCompanyIds = new Set(
(connectionRows ?? [])
.filter(
(r) => (r.value as GoogleDriveConnection | null)?.status === 'needs_reauth'
)
.map((r) => r.company_id as string)
)
const startTime = Date.now()
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
@@ -95,6 +128,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
const userId = row.user_id as string
const schedule = row.value as GoogleDriveSchedule
if (needsReauthCompanyIds.has(companyId)) {
// Do not touch last_auto_sync_* here: the schedule keeps showing the
// failure from the night the dead token was detected.
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
continue
}
try {
const syncResult = await performSync({
supabase,
@@ -141,11 +181,13 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
const successCount = results.filter((r) => r.status === 'success').length
const errorCount = results.filter((r) => r.status === 'error').length
const skippedCount = results.filter((r) => r.status === 'skipped').length
ctx.log.info('cloud backup cron summary', {
processed: results.length,
succeeded: successCount,
failed: errorCount,
skipped: skippedCount,
})
return NextResponse.json({
@@ -154,6 +196,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
processed: results.length,
successes: successCount,
errors: errorCount,
skipped: skippedCount,
results,
})
})