Files
ai-safety-console-web/tests/unit/auth/refresh.test.ts
T
baozaotumao2025 1b90e552a5 first commit
2026-07-18 21:10:39 +08:00

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())
})
})