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
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user