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
@@ -15,13 +15,11 @@
|
||||
"dependencies": {
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@authportal/ui": "workspace:*",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@base-ui/react": "catalog:",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
@@ -42,20 +40,21 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/js": "catalog:",
|
||||
"@tanstack/router-cli": "^1.167.31",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"globals": "catalog:",
|
||||
"jsdom": "^30.1.3",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"tw-animate-css": "^1.0.0",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,12 @@ export function ExplainBanner({
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void navigate({ to: '/design-preview/logins' })
|
||||
void navigate({
|
||||
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
|
||||
to: window.location.pathname.startsWith('/design-preview')
|
||||
? '/design-preview/logins'
|
||||
: '/admin/logins',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Открыть журнал входов
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// Node ≥ 25 имеет собственный экспериментальный localStorage, который затеняет
|
||||
// jsdom и без --localstorage-file не работает. Подставляем простую реализацию.
|
||||
class MemoryStorage implements Storage {
|
||||
private map = new Map<string, string>()
|
||||
get length() {
|
||||
return this.map.size
|
||||
}
|
||||
clear() {
|
||||
this.map.clear()
|
||||
}
|
||||
getItem(key: string) {
|
||||
return this.map.get(key) ?? null
|
||||
}
|
||||
key(index: number) {
|
||||
return [...this.map.keys()][index] ?? null
|
||||
}
|
||||
removeItem(key: string) {
|
||||
this.map.delete(key)
|
||||
}
|
||||
setItem(key: string, value: string) {
|
||||
this.map.set(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.localStorage?.clear !== 'function') {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: new MemoryStorage(),
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@authportal/shared': path.resolve(
|
||||
__dirname,
|
||||
'../../packages/shared/src/index.ts',
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
environment: 'jsdom',
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['test/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user