102 lines
4.9 KiB
TypeScript
102 lines
4.9 KiB
TypeScript
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<Response>((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<boolean>) => 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())
|
|
})
|
|
})
|