Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ on:

jobs:
verify:
runs-on: ubuntu-latest
# Pin the supported browser-test image; ubuntu-latest now selects 26.04.
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4

Expand Down
46 changes: 46 additions & 0 deletions apps/web/e2e/auth-sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,3 +589,49 @@ test('data safety: CRM customers, deals and follow-up tasks sync between two sig
await contextB.close()
}
})

test('data safety: expired session pauses once and same-account sign-in uploads preserved edits', async ({ browser }) => {
test.setTimeout(150_000)
const credentials = { email: `expiry-${crypto.randomUUID()}@example.test`, password: 'test-only-password-1234' }
const cookie = await authRequest('sign-up/email', { ...credentials, name: 'Session Recovery Test' })
const context = await browser.newContext({ serviceWorkers: 'block' })
try {
await context.addCookies([cookie])
await context.addInitScript(() => localStorage.setItem('workbench.ui.language', 'en'))
const page = await context.newPage()
await page.goto('/workspace/settings?section=data')
await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 })
await page.getByRole('switch', { name: 'Toggle cloud sync' }).uncheck()
const task = await page.evaluate(async () => {
const { createDexieDatabaseService } = await import('/src/data/services/DexieDatabaseService.ts')
return createDexieDatabaseService().tasks.add({ title: 'Preserve this edit across session expiry', description: 'Not uploaded yet', status: 'todo', priority: null, tags: [], subtasks: [] })
})
// Revoke the isolated server session without invoking client logout or data clearing.
await page.evaluate(async () => { await (await import('/src/config/authClient.ts')).authClient.signOut() })
let syncRequests = 0
page.on('request', request => { if (request.url().includes('/sync/rxdb/')) syncRequests++ })
await page.getByRole('switch', { name: 'Toggle cloud sync' }).check()
await expect(page.getByRole('button', { name: 'Sign in again', exact: true })).toBeVisible({ timeout: 45000 })
await expect(page.getByText('Your session has expired. Sync is paused and local changes are preserved. Sign in to the same account to resume.', { exact: true })).toBeVisible()
expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title)
await expect.poll(() => page.evaluate(async () => (await import('/src/data/db/index.ts')).db.pendingSyncChanges.count())).toBeGreaterThan(0)
const stoppedAt = syncRequests
await page.evaluate(() => { window.dispatchEvent(new Event('online')); window.dispatchEvent(new Event('focus')) })
await page.waitForTimeout(1500)
expect(syncRequests).toBe(stoppedAt)
await page.getByRole('button', { name: 'Sign in again', exact: true }).click()
const login = page.locator('.login-modal')
await login.locator('input[autocomplete="username"]').fill(credentials.email)
await login.locator('input[autocomplete="current-password"]').fill(credentials.password)
await login.locator('button[type="submit"]').click()
await expect(login).not.toBeVisible()
await expect(page.getByTestId('sync-safety-status')).toContainText('Cloud confirmed', { timeout: 45000 })
expect(await page.evaluate(async id => (await (await import('/src/data/db/index.ts')).db.tasks.get(id))?.title, task.id)).toBe(task.title)
const cloudHasTask = await page.evaluate(async id => {
const { syncApi } = await import('/src/data/sync/client.ts')
const result = await syncApi.rxdbPull({ entityType: 'tasks', checkpoint: null, limit: 100 })
return result.documents.some(row => row.id === id)
}, task.id)
expect(cloudHasTask).toBe(true)
} finally { await context.close() }
})
3 changes: 2 additions & 1 deletion apps/web/focus-go-api/auth/betterAuth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { betterAuth } from 'better-auth'
import { username } from 'better-auth/plugins'
import { bearer, username } from 'better-auth/plugins'
import dotenv from 'dotenv'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
Expand Down Expand Up @@ -62,6 +62,7 @@ export const auth = betterAuth({
},
socialProviders: googleProvider,
plugins: [
bearer(),
username({
minUsernameLength: 3,
maxUsernameLength: 30,
Expand Down
33 changes: 33 additions & 0 deletions apps/web/focus-go-api/auth/sessionRenewal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import Database from 'better-sqlite3'
import { betterAuth } from 'better-auth'
import { bearer } from 'better-auth/plugins'
import { getMigrations } from 'better-auth/db/migration'

test('bearer session renewal extends active sessions but never revives expired or revoked sessions', async () => {
const database = new Database(':memory:')
const options = {
database,
secret: 'isolated-session-renewal-test-secret-32chars',
baseURL: 'http://localhost:3000',
emailAndPassword: { enabled: true },
plugins: [bearer()],
session: { expiresIn: 3600, updateAge: 60 },
}
try {
await (await getMigrations(options)).runMigrations()
const auth = betterAuth(options)
const signup = await auth.api.signUpEmail({ body: { email: 'renewal@example.test', password: 'test-password-12345', name: 'Test' } })
const token = signup.token
const soon = new Date(Date.now() + 30_000)
database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(soon.toISOString(), token)
const headers = new Headers({ authorization: `Bearer ${token}` })
const renewed = await auth.api.getSession({ headers })
assert.equal(renewed.session.token, token)
assert.ok(new Date(renewed.session.expiresAt).getTime() > Date.now() + 3_000_000)
database.prepare('UPDATE session SET expiresAt = ? WHERE token = ?').run(new Date(Date.now() - 1000).toISOString(), token)
assert.equal(await auth.api.getSession({ headers }), null)
assert.equal(await auth.api.getSession({ headers: new Headers({ authorization: 'Bearer revoked-token' }) }), null)
} finally { database.close() }
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import express from 'express'
import cors from 'cors'
import Database from 'better-sqlite3'
import { betterAuth } from 'better-auth'
import { bearer } from 'better-auth/plugins'
import { toNodeHandler } from 'better-auth/node'
import { createSyncRouter } from '../../routes/sync.js'
import { SYNC_TABLES } from '../../sync/config.js'
Expand Down Expand Up @@ -71,6 +72,7 @@ app.use('/sync', createSyncRouter({ database, authMiddleware: requireTestAccount
const server = app.listen(0, '127.0.0.1', () => {
const port = server.address().port
auth = betterAuth({
plugins: [bearer()],
database,
secret: 'focusgo-browser-sync-test-secret-32chars',
baseURL: `http://127.0.0.1:${port}/api/auth`,
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/app/routes/SettingsRoute.theme-pack.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import SettingsRoute from './SettingsRoute'
import { THEME_BEFORE_MODE_TOGGLE_EVENT, clearThemePackPreview, getThemePalette } from '../../shared/theme/themePack'
import { applyTheme, writeStoredThemePreference } from '../../shared/theme/theme'

const syncMocks = vi.hoisted(() => ({ expired: false, sync: vi.fn() }))
vi.mock('../../data/sync/auth', () => ({ useSyncAuthRequired: () => syncMocks.expired }))
vi.mock('../../data/sync/service', () => ({ useSyncActions: () => ({ enabled: true, setEnabled: vi.fn(), syncNow: syncMocks.sync }), useSyncStatus: () => syncMocks.expired ? ({ status: 'error', lastError: 'internal error repeated', lastSyncedAt: null }) : null }))
vi.mock('../layout/LoginModal', () => ({ default: () => <div role="dialog" aria-label="Reauthenticate" /> }))

const mockDashboardGet = vi.fn()
const mockDashboardUpsert = vi.fn()

Expand All @@ -23,6 +28,8 @@ vi.mock('../../data/repositories/dashboardRepo', () => ({
},
}))

vi.mock('../../data/sync/conflicts', () => ({ listSyncConflicts: async () => [], markSyncConflictReviewed: vi.fn(), restoreSyncConflictAsCopy: vi.fn() }))

vi.mock('../../data/db', () => ({
db: {
delete: vi.fn(),
Expand Down Expand Up @@ -98,6 +105,7 @@ const chooseThemePack = async (optionText: string) => {

describe('SettingsRoute theme pack preview', () => {
beforeEach(() => {
syncMocks.expired = false
window.localStorage.clear()
applyTheme('light')
globalThis.ResizeObserver = class ResizeObserver {
Expand Down Expand Up @@ -131,6 +139,23 @@ describe('SettingsRoute theme pack preview', () => {
vi.clearAllMocks()
})

it('replaces repeated auth errors with a safe re-login action', async () => {
const { setAuth } = await import('../../store/auth')
setAuth({ accessToken: 'expired-test', user: { id: 'same-account' } })
syncMocks.expired = true
const user = userEvent.setup()
renderRoute()
try {
await user.click(screen.getByRole('tab', { name: /settings.module.data.title/ }))
expect(await screen.findByText('settings.data.sync.authRequired')).toBeVisible()
expect(screen.getByText('settings.data.sync.authStatus')).toBeVisible()
expect(screen.queryByText('internal error repeated')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'settings.data.sync.signInAgain' }))
expect(await screen.findByRole('dialog', { name: 'Reauthenticate' })).toBeVisible()
expect(syncMocks.sync).not.toHaveBeenCalled()
} finally { setAuth(null) }
})

it('previews selected theme pack immediately and does not persist it', async () => {
renderRoute()
await chooseThemePack('settings.themePack.option.b')
Expand Down
12 changes: 7 additions & 5 deletions apps/web/src/app/routes/SettingsRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AnonymousCountsSetting from '../../shared/metrics/AnonymousCountsSetting'
import { useSyncAuthRequired } from '../../data/sync/auth'
import { LEGAL_DOCUMENTS, policyUrl, type LegalDocument, type LegalDocumentKey } from '../../shared/legal/policies'
import { Suspense, lazy, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react'
import { motion } from 'motion/react'
Expand Down Expand Up @@ -597,6 +598,7 @@ const SettingsRoute = () => {
const { requireAuth, isGated } = useAuthGate()
const isLoggedIn = useIsLoggedIn()
const [showLoginModal, setShowLoginModal] = useState(false)
const syncAuthRequired = useSyncAuthRequired()
const cloudSyncQuota = useCloudSyncQuota()
const toast = useToast()
const syncState = useSyncStatus()
Expand Down Expand Up @@ -1685,7 +1687,7 @@ const SettingsRoute = () => {
aria-label={t('settings.data.sync.toggle')}
/>
</label>
<div className="text-sm text-muted-foreground">{syncStatusLabel}</div>
<div className="text-sm text-muted-foreground">{syncAuthRequired ? t('settings.data.sync.authStatus') : syncStatusLabel}</div>
<div className="text-xs text-muted-foreground">{lastSyncedLabel}</div>
{cloudSyncQuota ? <div className="text-xs text-muted-foreground">{t('settings.data.sync.quota', { used: (cloudSyncQuota.usedBytes / 1024 / 1024).toFixed(1), limit: (cloudSyncQuota.limitBytes / 1024 / 1024).toFixed(0) })}</div> : null}
{cloudSyncEnabled && syncState?.status === 'blocked' && syncConflicts.some((item) => item.accountId === getAuth()?.user?.id && !item.resolvedAt) ? (
Expand All @@ -1694,15 +1696,15 @@ const SettingsRoute = () => {
</div>
) : cloudSyncEnabled && syncState?.status === 'error' && syncState.lastError ? (
<div className="max-w-[360px] text-right text-xs text-destructive">
{t('settings.data.sync.error', { message: syncState.lastError })}
{syncAuthRequired ? t('settings.data.sync.authRequired') : t('settings.data.sync.error', { message: syncState.lastError })}
</div>
) : null}
<Button
variant="outline"
disabled={!cloudSyncEnabled || syncState?.status === 'syncing'}
onClick={() => { void syncNow() }}
disabled={!syncAuthRequired && (!cloudSyncEnabled || syncState?.status === 'syncing')}
onClick={() => { if (syncAuthRequired) setShowLoginModal(true); else void syncNow() }}
>
{t('settings.data.sync.action')}
{syncAuthRequired ? t('settings.data.sync.signInAgain') : t('settings.data.sync.action')}
</Button>
</div>
)}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/config/authClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const requestAuth = async <T>(path: string, init?: RequestInit): Promise<T> => {
const payload = parseAuthPayload(text, response.headers.get('content-type'))
if (!response.ok) {
const error = payload as AuthErrorPayload | null
throw new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`))
throw Object.assign(new Error(normalizeAuthErrorMessage(error, `Auth request failed (${response.status})`)), { status: response.status })
}
return payload as T
}
Expand Down Expand Up @@ -120,7 +120,7 @@ export const authClient = {
body: JSON.stringify({ code, state, verifier }),
}),

getSession: () => requestAuth<AuthResponse>('/get-session'),
getSession: (token?: string) => requestAuth<AuthResponse | null>('/get-session', { signal: AbortSignal.timeout(10_000), ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}) }),

signOut: () =>
requestAuth<unknown>('/sign-out', {
Expand Down
93 changes: 93 additions & 0 deletions apps/web/src/data/sync/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ session: vi.fn(), fetch: vi.fn(), save: vi.fn(), auth: { accessToken: 'old', user: { id: 'a' } } as { accessToken?: string; user: { id: string } } }))
vi.mock('../../config/authClient', () => ({ authClient: { getSession: mocks.session } }))
vi.mock('../../store/auth', () => ({ getAuth: () => mocks.auth, setAuth: (value: typeof mocks.auth) => { mocks.auth = value }, subscribeAuth: () => () => {} }))
vi.mock('../../shared/apiBase', () => ({ fetchApi: mocks.fetch }))
vi.mock('../../platform', () => ({ getPlatform: () => ({ saveAuthToken: mocks.save }) }))
const session = (token = 'old') => ({ session: { token }, user: { id: 'auth-a' } })
beforeEach(() => {
vi.resetModules()
mocks.auth = { accessToken: 'old', user: { id: 'a' } }
mocks.session.mockReset().mockResolvedValue(session())
mocks.fetch.mockReset().mockResolvedValue({ ok: true, json: async () => ({ id: 'a' }) })
mocks.save.mockReset().mockResolvedValue(undefined)
})

describe('sync session recovery', () => {
it('keeps an offline account hint without a token out of the expired state', async () => {
mocks.auth = { user: { id: 'a' } }
const online = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
try {
const { ensureSyncSession, isSyncAuthRequired, SyncAuthError } = await import('./auth')
const error = await ensureSyncSession().catch(error => error)
expect(error).not.toBeInstanceOf(SyncAuthError)
expect(error.message).toContain('offline')
expect(isSyncAuthRequired()).toBe(false)
expect(mocks.session).not.toHaveBeenCalled()
} finally { online.mockRestore() }
})
it('recovers an online account hint from its cookie without needing a token', async () => {
mocks.auth = { user: { id: 'a' } }
const { ensureSyncSession } = await import('./auth')
await ensureSyncSession()
expect(mocks.auth.accessToken).toBe('old')
expect(mocks.session).toHaveBeenCalledTimes(1)
})
it('deduplicates concurrent renewal and throttles successful checks', async () => {
const { ensureSyncSession } = await import('./auth')
await Promise.all([ensureSyncSession(), ensureSyncSession(), ensureSyncSession()])
await ensureSyncSession()
expect(mocks.session).toHaveBeenCalledTimes(1)
await ensureSyncSession(true)
expect(mocks.session).toHaveBeenCalledTimes(2)
})
it('recovers a missing cookie with a bearer session and persists the renewed token', async () => {
mocks.session.mockResolvedValueOnce(null).mockResolvedValueOnce(session('renewed'))
const { ensureSyncSession } = await import('./auth')
await ensureSyncSession()
expect(mocks.session.mock.calls).toEqual([[], ['old']])
expect(mocks.auth.accessToken).toBe('renewed')
expect(mocks.save).toHaveBeenCalledWith('renewed')
})
it('does not adopt another account from cookies', async () => {
mocks.session.mockResolvedValue(session('other-token'))
mocks.fetch.mockResolvedValue({ ok: true, json: async () => ({ id: 'b' }) })
const { ensureSyncSession, SyncAuthError } = await import('./auth')
await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
expect(mocks.auth).toEqual({ accessToken: 'old', user: { id: 'a' } })
expect(mocks.save).not.toHaveBeenCalled()
})
it('preserves identity when expired and resumes only after a new token', async () => {
mocks.session.mockResolvedValue(null)
const { ensureSyncSession, SyncAuthError, pauseSyncForAuth, isSyncAuthRequired } = await import('./auth')
await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
pauseSyncForAuth()
expect(isSyncAuthRequired()).toBe(true)
await expect(ensureSyncSession()).rejects.toBeInstanceOf(SyncAuthError)
expect(mocks.session).toHaveBeenCalledTimes(2)
expect(mocks.auth.user.id).toBe('a')
mocks.auth = { ...mocks.auth, accessToken: 'new-login' }
expect(isSyncAuthRequired()).toBe(false)
})
it('does not treat a network failure as expiration', async () => {
mocks.session.mockRejectedValue(new TypeError('Failed to fetch'))
const { ensureSyncSession, SyncAuthError, isSyncAuthRequired } = await import('./auth')
const error = await ensureSyncSession().catch(error => error)
expect(error).not.toBeInstanceOf(SyncAuthError)
expect(isSyncAuthRequired()).toBe(false)
expect(mocks.auth.accessToken).toBe('old')
})
it('discards renewal after another login completes', async () => {
let finish!: (value: ReturnType<typeof session>) => void
mocks.session.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const { ensureSyncSession } = await import('./auth')
const pending = ensureSyncSession()
mocks.auth = { accessToken: 'new-login', user: { id: 'b' } }
finish(session('renewed'))
await expect(pending).rejects.toThrow('Sync account changed')
expect(mocks.fetch).not.toHaveBeenCalled()
expect(mocks.auth.accessToken).toBe('new-login')
})
})
Loading
Loading