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).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, 'oops') const err = await api.get('/api/v1/x').catch((e) => e) expect(err).toBeInstanceOf(ApiError) expect(err.code).toBe('PARSE_ERROR') }) })