98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
clearToken,
|
|
getToken,
|
|
jwtClaimsStaleVsMe,
|
|
setToken,
|
|
} from '@/lib/auth'
|
|
import { ApiError, api } from '@/lib/api-client'
|
|
|
|
function jwt(payload: object): string {
|
|
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/=+$/, '')
|
|
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
|
|
}
|
|
const future = () => Math.floor(Date.now() / 1000) + 3600
|
|
|
|
beforeEach(() => localStorage.clear())
|
|
afterEach(() => vi.restoreAllMocks())
|
|
|
|
describe('хранилище токена', () => {
|
|
it('отдаёт валидный токен, удаляет просроченный', () => {
|
|
const good = jwt({ exp: future() })
|
|
setToken(good)
|
|
expect(getToken()).toBe(good)
|
|
|
|
setToken(jwt({ exp: 1 }))
|
|
expect(getToken()).toBeNull()
|
|
expect(localStorage.getItem('authportal_token')).toBeNull()
|
|
})
|
|
|
|
it('clearToken очищает', () => {
|
|
setToken(jwt({ exp: future() }))
|
|
clearToken()
|
|
expect(getToken()).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('jwtClaimsStaleVsMe', () => {
|
|
const me = {
|
|
id: 'u1',
|
|
email: 'a@b.co',
|
|
name: 'A',
|
|
is_admin: false,
|
|
apps: ['dns', 'vps'],
|
|
permissions: ['dns:dns:read'],
|
|
} as never
|
|
|
|
it('совпадающие клейм-ы не устарели (порядок не важен)', () => {
|
|
const t = jwt({ is_admin: false, apps: ['vps', 'dns'], permissions: ['dns:dns:read'] })
|
|
expect(jwtClaimsStaleVsMe(t, me)).toBe(false)
|
|
})
|
|
|
|
it('любое расхождение — устарел', () => {
|
|
expect(jwtClaimsStaleVsMe(jwt({ is_admin: true, apps: ['dns', 'vps'], permissions: ['dns:dns:read'] }), me)).toBe(true)
|
|
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns'], permissions: ['dns:dns:read'] }), me)).toBe(true)
|
|
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns', 'vps'], permissions: [] }), me)).toBe(true)
|
|
expect(jwtClaimsStaleVsMe('garbage', me)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('api-client', () => {
|
|
function mockFetch(status: number, body?: string) {
|
|
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(body ?? null, { status }),
|
|
)
|
|
}
|
|
|
|
it('добавляет Bearer и разбирает JSON', async () => {
|
|
const token = jwt({ exp: future() })
|
|
setToken(token)
|
|
const spy = mockFetch(200, JSON.stringify({ ok: 1 }))
|
|
await expect(api.get('/api/v1/x')).resolves.toEqual({ ok: 1 })
|
|
const init = spy.mock.calls[0]![1] as RequestInit
|
|
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${token}`)
|
|
expect(init.credentials).toBe('include')
|
|
})
|
|
|
|
it('204 → undefined', async () => {
|
|
mockFetch(204)
|
|
await expect(api.get('/api/v1/x')).resolves.toBeUndefined()
|
|
})
|
|
|
|
it('401 очищает токен и бросает ApiError с кодом сервера', async () => {
|
|
setToken(jwt({ exp: future() }))
|
|
mockFetch(401, JSON.stringify({ error: { code: 'UNAUTHORIZED', message: 'nope' } }))
|
|
const err = await api.get('/api/v1/x').catch((e) => e)
|
|
expect(err).toBeInstanceOf(ApiError)
|
|
expect(err).toMatchObject({ status: 401, code: 'UNAUTHORIZED', message: 'nope' })
|
|
expect(getToken()).toBeNull()
|
|
})
|
|
|
|
it('не-JSON тело ошибки → ApiError, а не падение парсера', async () => {
|
|
mockFetch(500, '<html>oops</html>')
|
|
const err = await api.get('/api/v1/x').catch((e) => e)
|
|
expect(err).toBeInstanceOf(ApiError)
|
|
expect(err.code).toBe('PARSE_ERROR')
|
|
})
|
|
})
|