first commit

This commit is contained in:
baozaotumao2025
2026-07-18 21:10:39 +08:00
commit 1b90e552a5
91 changed files with 9056 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { activity } from '../../../src/auth/activity'
describe('user activity', () => {
beforeEach(() => localStorage.clear())
it('records real input at most once every 30 seconds', () => {
const checkRefresh = vi.fn()
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
const stop = activity.start(checkRefresh)
window.dispatchEvent(new MouseEvent('click'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_010_000)
window.dispatchEvent(new KeyboardEvent('keydown'))
expect(activity.get()).toBe(1_000_000)
now.mockReturnValue(1_031_000)
window.dispatchEvent(new TouchEvent('touchstart'))
expect(activity.get()).toBe(1_031_000)
expect(checkRefresh).toHaveBeenCalledTimes(3)
stop()
})
it('checks on foreground resume without treating visibility as activity', () => {
const checkRefresh = vi.fn()
const stop = activity.start(checkRefresh)
document.dispatchEvent(new Event('visibilitychange'))
expect(checkRefresh).toHaveBeenCalledOnce()
expect(activity.get()).toBe(0)
window.dispatchEvent(new PopStateEvent('popstate'))
expect(activity.get()).toBeGreaterThan(0)
stop()
})
})
+101
View File
@@ -0,0 +1,101 @@
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())
})
})
+30
View File
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { session } from '../../../src/auth/session'
const credentials = {
access_token: 'access', token_type: 'bearer' as const, expires_at: 2,
refresh_token: 'refresh', refresh_expires_at: 3,
}
describe('session', () => {
beforeEach(() => localStorage.clear())
it('stores and replaces the complete credential set atomically', () => {
const listener = vi.fn()
const unsubscribe = session.subscribe(listener)
session.set(credentials)
expect(session.get()).toEqual(credentials)
expect(JSON.parse(localStorage.getItem('safety-session') || 'null')).toEqual(credentials)
expect(listener).toHaveBeenCalledOnce()
unsubscribe()
})
it('clears credentials and pending refresh state together', () => {
localStorage.setItem('safety-session', JSON.stringify(credentials))
localStorage.setItem('safety-refresh-pending', 'secret')
localStorage.setItem('safety-refresh-after', '2')
localStorage.setItem('safety-last-activity-at', '1')
session.clear()
expect(localStorage.length).toBe(0)
})
})