perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy
This commit is contained in:
1 parent
08be310e6b
commit
1a68597d48
31 files changed
+1313
-158
No files matched your search
@@ -0,0 +1,97 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user