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,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@authportal/db": "workspace:*",
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/formbody": "^9.0.0",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
@@ -22,22 +23,22 @@
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"fastify": "^5.4.0",
|
||||
"jose": "^6.2.8",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/js": "catalog:",
|
||||
"@types/node": "^22.15.32",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"eslint": "^10.7.0",
|
||||
"globals": "^17.7.0",
|
||||
"eslint": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -1,5 +1,6 @@
|
||||
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import compress from '@fastify/compress'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
@@ -51,6 +52,9 @@ import { createOriginChecker } from './lib/cors.js'
|
||||
|
||||
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
|
||||
function boolFlag(v: string): boolean {
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
|
||||
const INDEX_HTML_RE = /[\\/]index\.html$/
|
||||
|
||||
@@ -167,14 +171,34 @@ export async function buildApp(opts: {
|
||||
const originAllowed = createOriginChecker(config, () =>
|
||||
getAppSwitcherConfig(db).apps.map((a) => a.url),
|
||||
)
|
||||
if (boolFlag(config.enableCompress)) {
|
||||
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
|
||||
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
|
||||
}
|
||||
await app.register(formbody)
|
||||
await app.register(cors, {
|
||||
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
|
||||
credentials: true,
|
||||
})
|
||||
await app.register(helmet, { contentSecurityPolicy: false })
|
||||
await app.register(helmet, {
|
||||
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await app.register(rateLimit, {
|
||||
max: 200,
|
||||
max: config.rateLimitMax,
|
||||
timeWindow: '1 minute',
|
||||
// пробы Docker/Traefik не должны упираться в лимит
|
||||
allowList: (req) => req.url === '/health' || req.url === '/ready',
|
||||
|
||||
@@ -39,6 +39,10 @@ export const configSchema = z.object({
|
||||
backupKeep: z.coerce.number().int().positive().default(7),
|
||||
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
|
||||
backupIntervalHours: z.coerce.number().min(0).default(0),
|
||||
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
|
||||
enableCompress: z.string().default('false'),
|
||||
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
|
||||
rateLimitMax: z.coerce.number().int().positive().default(200),
|
||||
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
|
||||
corsOrigins: z.string().default(''),
|
||||
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
|
||||
@@ -121,6 +125,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
backupDir: env.BACKUP_DIR || undefined,
|
||||
backupKeep: env.BACKUP_KEEP ?? 7,
|
||||
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
|
||||
enableCompress: env.ENABLE_COMPRESS ?? 'false',
|
||||
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
|
||||
corsOrigins: env.CORS_ORIGINS ?? '',
|
||||
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
|
||||
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
|
||||
|
||||
@@ -36,6 +36,18 @@ describe('Dockerfile', () => {
|
||||
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||
})
|
||||
|
||||
it('preview-маршруты дизайна вырезаются до сборки web', () => {
|
||||
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
|
||||
const build = dockerfile.indexOf('pnpm turbo build')
|
||||
expect(rm).toBeGreaterThan(0)
|
||||
expect(rm).toBeLessThan(build)
|
||||
})
|
||||
|
||||
it('базовый образ закреплён по digest', () => {
|
||||
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
|
||||
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||
})
|
||||
|
||||
it('нативные модули проверяются при сборке', () => {
|
||||
expect(dockerfile).toContain('native modules ok')
|
||||
})
|
||||
@@ -59,6 +71,13 @@ describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||
})
|
||||
|
||||
describe('traefik', () => {
|
||||
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
|
||||
const t = composes['deploy/docker-compose.traefik.yml']
|
||||
expect(t).toMatch(/condition: service_healthy/)
|
||||
expect(t).toContain('traefik", "healthcheck", "--ping')
|
||||
expect(t).toContain('--ping=true')
|
||||
})
|
||||
|
||||
it('включено сжатие ответов', () => {
|
||||
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||
'middlewares.auth-portal-compress.compress=true',
|
||||
|
||||
@@ -178,3 +178,55 @@ describe('CORS', () => {
|
||||
expect(all('https://anything.example')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
|
||||
async function withBigRoute(env: NodeJS.ProcessEnv) {
|
||||
app = await buildTestApp(env)
|
||||
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
|
||||
app.get('/__small', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
it('по умолчанию выключено', async () => {
|
||||
const a = await withBigRoute({})
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
|
||||
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
|
||||
const big = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(big.headers['content-encoding']).toBe('gzip')
|
||||
expect(big.rawPayload.length).toBeLessThan(500)
|
||||
const small = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__small',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(small.headers['content-encoding']).toBeUndefined()
|
||||
const none = await a.inject({ method: 'GET', url: '/__big' })
|
||||
expect(none.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
});
|
||||
|
||||
describe('CSP', () => {
|
||||
it('отдаётся жёсткая политика без внешних источников', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
const csp = String(res.headers['content-security-policy'])
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain("script-src 'self'")
|
||||
expect(csp).toContain("frame-ancestors 'none'")
|
||||
expect(csp).toContain("object-src 'none'")
|
||||
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
|
||||
expect(csp).not.toContain('http:')
|
||||
})
|
||||
})
|
||||
@@ -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