Files
auth-portal/apps/web/test/auth-client.test.ts
T

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