import { beforeEach, describe, expect, it, vi } from 'vitest' import { activity } from '../../../src/auth/activity' import { createAuthRefresh } from '../../../src/auth/refresh' import { session } from '../../../src/auth/session' const now = 1_800_000_000 const credentials = (overrides = {}) => ({ access_token: 'access-a', token_type: 'bearer' as const, expires_at: now + 1_800, refresh_token: 'refresh-a', refresh_expires_at: now + 28_800, ...overrides, }) const refreshed = credentials({ access_token: 'access-b', refresh_token: 'refresh-b', expires_at: now + 3_600 }) describe('auth refresh', () => { beforeEach(() => { localStorage.clear() vi.restoreAllMocks() vi.spyOn(Date, 'now').mockReturnValue(now * 1000) session.set(credentials()) activity.record(true) }) it('does not refresh before the renewal window or while inactive', async () => { const fetcher = vi.fn() session.set(credentials({ expires_at: now + 1_801 })) expect(await createAuthRefresh('/api', fetcher)()).toBe(false) session.set(credentials()) localStorage.setItem('safety-last-activity-at', String((now - 1_801) * 1000)) expect(await createAuthRefresh('/api', fetcher)()).toBe(false) expect(fetcher).not.toHaveBeenCalled() }) it('uses one in-flight refresh and atomically rotates all credentials', async () => { let resolve!: (response: Response) => void const fetcher = vi.fn(() => new Promise((done) => { resolve = done })) const refresh = createAuthRefresh('/api', fetcher) const first = refresh() const second = refresh() resolve(Response.json(refreshed)) await expect(Promise.all([first, second])).resolves.toEqual([true, true]) expect(fetcher).toHaveBeenCalledOnce() expect(session.get()).toEqual(refreshed) }) it('reuses the refresh token and idempotency key for network and 5xx retries', async () => { const fetcher = vi.fn() .mockRejectedValueOnce(new TypeError('offline')) .mockResolvedValueOnce(Response.json({ error: { message: 'down' } }, { status: 503 })) .mockResolvedValueOnce(Response.json(refreshed)) await expect(createAuthRefresh('/api', fetcher)()).resolves.toBe(true) const calls = fetcher.mock.calls.map(([, init]) => ({ key: new Headers(init?.headers).get('Idempotency-Key'), body: init?.body, })) expect(new Set(calls.map(({ key }) => key)).size).toBe(1) expect(new Set(calls.map(({ body }) => body)).size).toBe(1) }) it('keeps the same idempotency key after all network retries fail', async () => { const offline = vi.fn().mockRejectedValue(new TypeError('offline')) await expect(createAuthRefresh('/api', offline)()).rejects.toMatchObject({ kind: 'network' }) const firstKey = new Headers(offline.mock.calls[0][1]?.headers).get('Idempotency-Key') const recovered = vi.fn().mockResolvedValue(Response.json(refreshed)) await expect(createAuthRefresh('/api', recovered)()).resolves.toBe(true) expect(new Headers(recovered.mock.calls[0][1]?.headers).get('Idempotency-Key')).toBe(firstKey) }) it('coordinates refreshes with the browser cross-tab lock', async () => { const request = vi.fn(async (_name: string, callback: () => Promise) => callback()) Object.defineProperty(navigator, 'locks', { configurable: true, value: { request } }) await createAuthRefresh('/api', vi.fn().mockResolvedValue(Response.json(refreshed)))() expect(request).toHaveBeenCalledWith('safety-auth-refresh', expect.any(Function)) Object.defineProperty(navigator, 'locks', { configurable: true, value: undefined }) }) it('keeps credentials on 409 and clears them only when refresh returns 401', async () => { const conflictFetch = vi.fn().mockResolvedValue(Response.json({ error: { code: 'REFRESH_NOT_DUE', message: 'not due', details: { refresh_after: now + 60 } }, }, { status: 409 })) const conflict = createAuthRefresh('/api', conflictFetch) await expect(conflict()).resolves.toBe(false) await expect(conflict()).resolves.toBe(false) expect(conflictFetch).toHaveBeenCalledOnce() expect(session.get()).toEqual(credentials()) localStorage.removeItem('safety-refresh-after') const unauthorized = createAuthRefresh('/api', vi.fn().mockResolvedValue( Response.json({ error: { code: 'UNAUTHORIZED', message: 'expired' } }, { status: 401 }), )) await expect(unauthorized(true)).rejects.toMatchObject({ kind: 'authentication' }) expect(session.get()).toBeNull() expect(localStorage.getItem('safety-refresh-pending')).toBeNull() }) it('does not retry malformed refresh requests', async () => { const fetcher = vi.fn().mockResolvedValue(Response.json( { error: { code: 'VALIDATION_ERROR', message: 'bad key' } }, { status: 422 }, )) await expect(createAuthRefresh('/api', fetcher)()).rejects.toMatchObject({ kind: 'validation' }) expect(fetcher).toHaveBeenCalledOnce() expect(session.get()).toEqual(credentials()) }) })