perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy

This commit is contained in:
Denozordec committed 2026-10-23 17:22:23 +07:00
1 parent 08be310e6b
commit 1a68597d48
31 files changed
+1313 -158

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
name: setup-pnpm
description: Node 22 + кэш pnpm store / corepack / node_modules / .turbo по хешу lockfile
outputs:
cache-hit:
description: Был ли точный хит кэша (передайте в PNPM_CACHE_HIT для scripts/ci/pnpm-ci.sh)
value: ${{ steps.pnpm-cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
- name: Export cache paths
shell: sh
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
shell: bash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
+2
View File
@@ -129,6 +129,8 @@ jobs:
env: env:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }} REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest IMAGE_TAG: latest
# provenance + SBOM как attestations образа (только при push в registry)
ATTEST: "true"
VERSION: ${{ steps.meta.outputs.version }} VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }} SHA_FULL: ${{ github.sha }}
+49
View File
@@ -0,0 +1,49 @@
# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS.
name: nightly-perf
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
load:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: Build image
working-directory: deploy/docker
env:
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
run: |
set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal
- name: Run container with production limits
run: |
set -euxo pipefail
docker volume create load-data
docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -p 18080:8080 \
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
-e ADMIN_EMAIL=admin@smoke.local -e ISSUER=http://127.0.0.1:18080 -e RATE_LIMIT_MAX=1000000 authportal:load
for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done
- name: Load test
env:
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
LOAD_PASSWORD: load-admin-pass-1
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
node scripts/perf/load.mjs http://127.0.0.1:18080 authportal-load
- name: Container logs on failure
if: failure()
run: docker logs --tail 100 authportal-load || true
+14 -72
View File
@@ -110,7 +110,7 @@ jobs:
while IFS= read -r f || [ -n "${f:-}" ]; do while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue [ -z "${f:-}" ] && continue
case "$f" in case "$f" in
.gitea/workflows/*|scripts/*) .gitea/workflows/*|.gitea/actions/*|scripts/*)
full_pipeline=true full_pipeline=true
;; ;;
.cursor/*|.claude/*|.codegraph/*|.agents/*) .cursor/*|.claude/*|.codegraph/*|.agents/*)
@@ -160,32 +160,11 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, lint, typecheck, build - name: pnpm install, lint, typecheck, build
env: env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
@@ -202,32 +181,11 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
.turbo
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: pnpm install, lint, test, typecheck, build - name: pnpm install, lint, test, typecheck, build
env: env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
@@ -244,33 +202,13 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - id: setup
with: uses: ./.gitea/actions/setup-pnpm
node-version: "22"
- name: Export cache paths
run: sh scripts/ci/export-cache-env.sh
- id: pnpm-hash
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- id: pnpm-cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
${{ env.PNPM_STORE_DIR }}
${{ env.COREPACK_HOME }}
node_modules
apps/web/node_modules
apps/api/node_modules
packages/ui/node_modules
packages/shared/node_modules
packages/db/node_modules
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
restore-keys: |
pnpm-${{ runner.os }}-
- name: Lint commit messages - name: Lint commit messages
env: env:
BASE_SHA: ${{ inputs.base_sha }} BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }} HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }} PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: | run: |
set -euxo pipefail set -euxo pipefail
sh scripts/ci/pnpm-ci.sh sh scripts/ci/pnpm-ci.sh
@@ -323,3 +261,7 @@ jobs:
set -euxo pipefail set -euxo pipefail
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:smoke' authportal
bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke bash "${{ github.workspace }}/scripts/ci/smoke-image.sh" authportal:smoke
- name: trivy (HIGH/CRITICAL; информативно, пайплайн не блокирует)
continue-on-error: true
run: |
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.58.1 image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 0 authportal:smoke
+6 -5
View File
@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@authportal/db": "workspace:*", "@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*", "@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1", "@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0", "@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1", "@fastify/helmet": "^13.0.1",
@@ -22,22 +23,22 @@
"@fastify/rate-limit": "^10.3.0", "@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.2.0", "@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"better-sqlite3": "^11.10.0",
"@simplewebauthn/server": "^13.3.2", "@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0", "fastify": "^5.4.0",
"jose": "^6.2.8", "jose": "^6.2.8",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@types/node": "^22.15.32", "@types/node": "^22.15.32",
"@vitest/coverage-v8": "4.1.10", "@vitest/coverage-v8": "4.1.10",
"eslint": "^10.7.0", "eslint": "catalog:",
"globals": "^17.7.0", "globals": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.64.0", "typescript-eslint": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+26 -2
View File
@@ -1,5 +1,6 @@
import type { FastifyError, FastifyInstance } from 'fastify' import type { FastifyError, FastifyInstance } from 'fastify'
import Fastify from 'fastify' import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import helmet from '@fastify/helmet' import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit' import rateLimit from '@fastify/rate-limit'
@@ -51,6 +52,9 @@ import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */ /** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/'] 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 HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/ const INDEX_HTML_RE = /[\\/]index\.html$/
@@ -167,14 +171,34 @@ export async function buildApp(opts: {
const originAllowed = createOriginChecker(config, () => const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url), 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(formbody)
await app.register(cors, { await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)), origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true, 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, { await app.register(rateLimit, {
max: 200, max: config.rateLimitMax,
timeWindow: '1 minute', timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит // пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready', allowList: (req) => req.url === '/health' || req.url === '/ready',
+6
View File
@@ -39,6 +39,10 @@ export const configSchema = z.object({
backupKeep: z.coerce.number().int().positive().default(7), backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */ /** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0), 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 через запятую ('*' — все). */ /** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''), corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */ /** Одновременных argon2-хешей (каждый ~19 МиБ). */
@@ -121,6 +125,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
backupDir: env.BACKUP_DIR || undefined, backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7, backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0, backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '', corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2, argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY, trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
+19
View File
@@ -36,6 +36,18 @@ describe('Dockerfile', () => {
expect(dockerfile).not.toMatch(/gzip -k/) 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('нативные модули проверяются при сборке', () => { it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok') expect(dockerfile).toContain('native modules ok')
}) })
@@ -59,6 +71,13 @@ describe.each(Object.entries(composes))('%s', (_name, text) => {
}) })
describe('traefik', () => { 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('включено сжатие ответов', () => { it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain( expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true', 'middlewares.auth-portal-compress.compress=true',
+52
View File
@@ -178,3 +178,55 @@ describe('CORS', () => {
expect(all('https://anything.example')).toBe(true) 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:')
})
})
+6 -7
View File
@@ -15,13 +15,11 @@
"dependencies": { "dependencies": {
"@authportal/shared": "workspace:*", "@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*", "@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0", "@base-ui/react": "catalog:",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
@@ -42,20 +40,21 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31", "@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0", "eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1", "rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0", "tw-animate-css": "^1.0.0",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.59.2", "typescript-eslint": "catalog:",
"vite": "^8.0.12", "vite": "^8.0.12",
"vitest": "catalog:" "vitest": "catalog:"
} }
@@ -51,7 +51,12 @@ export function ExplainBanner({
<Button <Button
onClick={() => { onClick={() => {
setOpen(false) setOpen(false)
void navigate({ to: '/design-preview/logins' }) void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}} }}
> >
Открыть журнал входов Открыть журнал входов
+97
View File
@@ -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')
})
})
+30
View File
@@ -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,
})
}
+13
View File
@@ -1,7 +1,20 @@
import path from 'path'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@authportal/shared': path.resolve(
__dirname,
'../../packages/shared/src/index.ts',
),
},
},
test: { test: {
passWithNoTests: true, passWithNoTests: true,
environment: 'jsdom',
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
}, },
}) })
+9 -1
View File
@@ -29,6 +29,7 @@ services:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --providers.docker.network=auth-portal - --providers.docker.network=auth-portal
- --ping=true
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443 - --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.to=websecure
@@ -43,6 +44,12 @@ services:
- traefik_letsencrypt:/letsencrypt - traefik_letsencrypt:/letsencrypt
networks: networks:
- auth-portal - auth-portal
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -69,7 +76,8 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
depends_on: depends_on:
- traefik traefik:
condition: service_healthy
# No host ports — only Traefik publishes 80/443. # No host ports — only Traefik publishes 80/443.
env_file: env_file:
- .env - .env
+6 -1
View File
@@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
# All-in-one Auth Portal (Fastify + SPA + SQLite). Analog cfdm / evofw. # All-in-one Auth Portal (Fastify + SPA + SQLite). Analog cfdm / evofw.
ARG BASE_NODE=docker.io/library/node:22-alpine # Закреплён по digest (воспроизводимая сборка); обновляйте вместе с node:22-alpine
# (digest: docker buildx imagetools inspect node:22-alpine).
ARG BASE_NODE=docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914
FROM ${BASE_NODE} AS deps FROM ${BASE_NODE} AS deps
WORKDIR /repo WORKDIR /repo
@@ -22,6 +24,9 @@ COPY apps/api apps/api
COPY packages/ui packages/ui COPY packages/ui packages/ui
COPY packages/shared packages/shared COPY packages/shared packages/shared
COPY packages/db packages/db COPY packages/db packages/db
# Preview-маршруты дизайна (mock-данные) не нужны в проде: вырезаем до генерации routeTree
# (-45% файлов и ~-10% gzip в бандле). Общие компоненты из src/design-preview остаются.
RUN rm -f apps/web/src/routes/design-preview*.tsx
# API собирается tsup в один файл (все зависимости внутри, кроме нативных). # API собирается tsup в один файл (все зависимости внутри, кроме нативных).
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@authportal/api pnpm turbo build --filter=web --filter=@authportal/api
+7 -1
View File
@@ -30,8 +30,13 @@ variable "CACHE_REF_NODE" {
default = "" default = ""
} }
# provenance + SBOM; включается только при --push (docker exporter attestations не поддерживает).
variable "ATTEST" {
default = "false"
}
variable "BASE_NODE" { variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine" default = "docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914"
} }
function "node-cache-from" { function "node-cache-from" {
@@ -113,5 +118,6 @@ target "authportal" {
build = "target:node-build" build = "target:node-build"
} }
cache-from = node-cache-from() cache-from = node-cache-from()
attest = equal(ATTEST, "true") ? ["type=provenance,mode=min", "type=sbom"] : []
tags = concat(image-tags("authportal"), image-tags("auth-portal")) tags = concat(image-tags("authportal"), image-tags("auth-portal"))
} }
+4
View File
@@ -44,6 +44,10 @@ NODE_ENV=production
# SESSION_RETENTION_DAYS=7 # SESSION_RETENTION_DAYS=7
# Доверенные прокси для X-Forwarded-For (IP/CIDR через запятую, true/false) # Доверенные прокси для X-Forwarded-For (IP/CIDR через запятую, true/false)
# TRUST_PROXY=127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7 # TRUST_PROXY=127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
# Сжатие ответов в Node (нужно только без Traefik compress)
# ENABLE_COMPRESS=false
# Глобальный лимит запросов на IP в минуту
# RATE_LIMIT_MAX=200
# Доп. origins для CORS (по умолчанию: issuer, RETURN_TO_ALLOWLIST, приложения App Switcher) # Доп. origins для CORS (по умолчанию: issuer, RETURN_TO_ALLOWLIST, приложения App Switcher)
# CORS_ORIGINS= # CORS_ORIGINS=
# Бэкап SQLite: период в часах (0 = выключен), число копий, каталог # Бэкап SQLite: период в часах (0 = выключен), число копий, каталог
+27
View File
@@ -15,12 +15,39 @@
| Образ | API бандлится tsup в один файл, в образ ставятся только нативные модули; статика только `.br`; healthcheck через `wget`; `NODE_OPTIONS=--max-old-space-size=160`; лимиты compose: 256 МиБ, 1 CPU, `read_only`, `cap_drop: ALL`. | | Образ | API бандлится tsup в один файл, в образ ставятся только нативные модули; статика только `.br`; healthcheck через `wget`; `NODE_OPTIONS=--max-old-space-size=160`; лимиты compose: 256 МиБ, 1 CPU, `read_only`, `cap_drop: ALL`. |
| Бэкап | `docker exec auth-portal node dist/server.js backup` или `BACKUP_INTERVAL_HOURS`; онлайн-копия, `integrity_check`, ротация. | | Бэкап | `docker exec auth-portal node dist/server.js backup` или `BACKUP_INTERVAL_HOURS`; онлайн-копия, `integrity_check`, ротация. |
Дополнительно: подготовленные запросы (`prepare` один раз на соединение),
строгая CSP (`'self'`, без inline-скриптов), опциональное сжатие в Node
(`ENABLE_COMPRESS=true`, если нет Traefik compress), базовый образ закреплён по
digest, `design-preview`-маршруты вырезаются из прод-образа, healthcheck Traefik
и `depends_on: service_healthy`, provenance + SBOM при публикации, Trivy
(информативно), общий composite-action CI, `inputs` в `turbo.json`.
## Замеры (локально, Windows, Node 25; не Linux-контейнер)
| Что | Результат |
|---|---|
| `listActiveSessions` по пользователю на 100k сессий | ~1.6 мс |
| `runHousekeeping`, 90k устаревших сессий | ~220 мс |
| `appendAuditBatch`, 10k событий одной транзакцией | ~610 мс |
| `listAudit(kind=logins)` на 10k | ~5 мс |
| 20k prepared-запросов профиля | ~28 мс |
| HTTP `GET /health` (autocannon, 50 соединений) | ~15 000 rps, p99 7 мс |
| HTTP `GET /api/v1/auth/me` (JWT, кэш профиля) | ~8 300 rps, p99 11 мс |
| web: файлов в `dist/assets` | 87 → 48 без preview-маршрутов; gzip JS 484 → 437 КБ |
| Рабочий набор процесса (Windows) | ~117 МиБ в покое, ~237 МиБ после потока 8 с при куче 160 МБ |
Рабочий набор Windows не равен RSS контейнера Linux. Реальный RSS под лимитом
256 МиБ мерит `scripts/ci/smoke-image.sh` и ночной `nightly-perf` — при
превышении увеличьте `mem_limit` и `NODE_OPTIONS` вместе.
## Проверка ## Проверка
```bash ```bash
pnpm turbo test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared pnpm turbo test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm --filter web build && node scripts/ci/web-budget.mjs apps/web/dist/assets pnpm --filter web build && node scripts/ci/web-budget.mjs apps/web/dist/assets
bash scripts/ci/smoke-image.sh <image> # размер, RSS, /ready, docker stop, данные после рестарта bash scripts/ci/smoke-image.sh <image> # размер, RSS, /ready, docker stop, данные после рестарта
pnpm --filter @authportal/db test:perf # бюджеты производительности БД
node scripts/perf/load.mjs http://127.0.0.1:8080 [container] # autocannon (ночью: nightly-perf.yaml)
``` ```
Бюджеты smoke-теста (env): `MAX_IMAGE_MB=250`, `MAX_RSS_MB=200`, `MAX_START_S=15`, Бюджеты smoke-теста (env): `MAX_IMAGE_MB=250`, `MAX_RSS_MB=200`, `MAX_START_S=15`,
+6 -1
View File
@@ -16,12 +16,17 @@
"@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0", "@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3", "@semantic-release/release-notes-generator": "^14.0.3",
"autocannon": "7.15.0",
"conventional-commits-parser": "^6.4.0", "conventional-commits-parser": "^6.4.0",
"semantic-release": "^25.0.2", "semantic-release": "^25.0.2",
"turbo": "^2.5.4" "turbo": "^2.5.4"
}, },
"packageManager": "pnpm@10.12.1", "packageManager": "pnpm@10.12.1",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "@node-rs/argon2"] "onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"@node-rs/argon2"
]
} }
} }
+5 -4
View File
@@ -21,6 +21,7 @@
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:perf": "vitest run --config vitest.perf.config.ts",
"dev": "tsup src/index.ts --format esm --dts --watch", "dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push" "db:push": "drizzle-kit push"
@@ -31,15 +32,15 @@
"drizzle-orm": "^0.44.2" "drizzle-orm": "^0.44.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "4.1.10", "@vitest/coverage-v8": "4.1.10",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.1",
"eslint": "^10.7.0", "eslint": "catalog:",
"globals": "^17.7.0", "globals": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.64.0", "typescript-eslint": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+48 -16
View File
@@ -29,8 +29,48 @@ export function listAdmins(db: AppDb): UserRow[] {
return db.select().from(users).where(eq(users.isAdmin, true)).all() return db.select().from(users).where(eq(users.isAdmin, true)).all()
} }
/**
* Подготовленные запросы горячего пути: компиляция SQL один раз на соединение
* вместо prepare на каждый вызов.
*/
function buildPrepared(db: AppDb) {
return {
userById: db
.select()
.from(users)
.where(eq(users.id, sql.placeholder('id')))
.prepare(),
apps: db
.select({ appId: userApps.appId })
.from(userApps)
.where(eq(userApps.userId, sql.placeholder('userId')))
.prepare(),
permissions: db
.select({ permission: userPermissions.permission })
.from(userPermissions)
.where(eq(userPermissions.userId, sql.placeholder('userId')))
.prepare(),
sessionByHash: db
.select()
.from(refreshSessions)
.where(eq(refreshSessions.tokenHash, sql.placeholder('hash')))
.prepare(),
}
}
const preparedByDb = new WeakMap<AppDb, ReturnType<typeof buildPrepared>>()
function prepared(db: AppDb) {
let p = preparedByDb.get(db)
if (!p) {
p = buildPrepared(db)
preparedByDb.set(db, p)
}
return p
}
export function getUserById(db: AppDb, id: string): UserRow | undefined { export function getUserById(db: AppDb, id: string): UserRow | undefined {
return db.select().from(users).where(eq(users.id, id)).get() return prepared(db).userById.get({ id })
} }
export function getUserByEmail( export function getUserByEmail(
@@ -45,20 +85,14 @@ export function getUserByEmail(
} }
export function getUserApps(db: AppDb, userId: string): string[] { export function getUserApps(db: AppDb, userId: string): string[] {
return db return prepared(db)
.select() .apps.all({ userId })
.from(userApps)
.where(eq(userApps.userId, userId))
.all()
.map((r) => r.appId) .map((r) => r.appId)
} }
export function getUserPermissions(db: AppDb, userId: string): string[] { export function getUserPermissions(db: AppDb, userId: string): string[] {
return db return prepared(db)
.select() .permissions.all({ userId })
.from(userPermissions)
.where(eq(userPermissions.userId, userId))
.all()
.map((r) => r.permission) .map((r) => r.permission)
} }
@@ -258,11 +292,9 @@ export function getUserByRefreshToken(
rawToken: string, rawToken: string,
): UserRow | undefined { ): UserRow | undefined {
const now = new Date().toISOString() const now = new Date().toISOString()
const session = db const session = prepared(db).sessionByHash.get({
.select() hash: hashToken(rawToken),
.from(refreshSessions) })
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
.get()
if (!session || session.revokedAt) return undefined if (!session || session.revokedAt) return undefined
if (session.expiresAt < now) return undefined if (session.expiresAt < now) return undefined
const user = getUserById(db, session.userId) const user = getUserById(db, session.userId)
+36
View File
@@ -17,6 +17,7 @@ import {
createWebauthnCredential, createWebauthnCredential,
getActiveOidcSigningKey, getActiveOidcSigningKey,
getUserApps, getUserApps,
getUserById,
getUserPermissions, getUserPermissions,
insertOidcSigningKey, insertOidcSigningKey,
listActiveSessions, listActiveSessions,
@@ -388,3 +389,38 @@ describe('runHousekeeping', () => {
expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500) expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500)
}) })
}) })
describe('подготовленные запросы', () => {
it('горячие запросы не вызывают prepare повторно и дают прежние результаты', () => {
const { db, sqlite } = memDb()
const u = mkUser(db, 'p@x.io')
setUserAccess(db, u.id, ['dns', 'vps'], ['dns:dns:read'])
// прогрев: первая подготовка
getUserById(db, u.id)
getUserApps(db, u.id)
getUserPermissions(db, u.id)
const calls: string[] = []
const orig = sqlite.prepare.bind(sqlite)
sqlite.prepare = ((sql: string) => {
calls.push(sql)
return orig(sql)
}) as typeof sqlite.prepare
for (let i = 0; i < 20; i++) {
expect(getUserById(db, u.id)?.email).toBe('p@x.io')
expect(getUserApps(db, u.id).sort()).toEqual(['dns', 'vps'])
expect(getUserPermissions(db, u.id)).toEqual(['dns:dns:read'])
}
expect(getUserById(db, 'missing')).toBeUndefined()
expect(calls).toHaveLength(0)
})
it('независимые соединения не делят подготовленные запросы', () => {
const a = memDb()
const b = memDb()
const ua = mkUser(a.db, 'a@x.io')
expect(getUserById(a.db, ua.id)).toBeDefined()
expect(getUserById(b.db, ua.id)).toBeUndefined()
})
})
+100
View File
@@ -0,0 +1,100 @@
import { afterAll, describe, expect, it } from 'vitest'
import {
appendAuditBatch,
closeDb,
createDb,
createUser,
getUserApps,
getUserById,
listActiveSessions,
listAudit,
migrateSchema,
runHousekeeping,
searchDirectoryUsers,
} from '../../src/index.js'
const { db, sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
afterAll(() => closeDb(sqlite))
function timed<T>(fn: () => T): { ms: number; value: T } {
const t0 = performance.now()
const value = fn()
return { ms: performance.now() - t0, value }
}
// Бюджеты щедрые (CI-раннеры шумные): ловим деградацию на порядок, а не на проценты.
describe('бюджеты производительности БД', () => {
const users = Array.from({ length: 200 }, (_, i) =>
createUser(db, {
email: `u${i}@x.io`,
name: `User ${i}`,
passwordHash: 'h',
isAdmin: false,
}),
)
it('вставка 100k сессий и фильтр активных по пользователю < 150 мс', () => {
const ins = sqlite.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES (?,?,?,?,?)`,
)
const future = new Date(Date.now() + 86_400_000).toISOString()
const past = '2000-01-01T00:00:00.000Z'
sqlite.transaction(() => {
for (let i = 0; i < 100_000; i++) {
ins.run(`s${i}`, users[i % users.length]!.id, `h${i}`, i % 10 === 0 ? future : past, `t${i}`)
}
})()
listActiveSessions(db, { userId: users[0]!.id }) // прогрев
const { ms, value } = timed(() => listActiveSessions(db, { userId: users[0]!.id }))
console.log(`listActiveSessions(user) на 100k: ${ms.toFixed(1)} мс, ${value.length} строк`)
expect(value.length).toBeGreaterThan(0)
expect(ms).toBeLessThan(150)
})
it('housekeeping 100k истёкших сессий < 3 с', () => {
const { ms, value } = timed(() => runHousekeeping(db, { retentionDays: 1 }))
console.log(`runHousekeeping: ${ms.toFixed(0)} мс, удалено ${value.sessions}`)
expect(value.sessions).toBeGreaterThan(80_000)
expect(ms).toBeLessThan(3000)
})
it('пакетный ingest 10k событий одной транзакцией < 1.5 с', () => {
const events = Array.from({ length: 10_000 }, (_, i) => ({
eventId: `e${i}`,
action: i % 2 ? 'auth.login' : 'user.update',
summary: 'bench',
}))
const { ms, value } = timed(() => appendAuditBatch(db, events))
console.log(`appendAuditBatch(10k): ${ms.toFixed(0)} мс`)
expect(value.accepted).toBe(10_000)
expect(ms).toBeLessThan(1500)
})
it('listAudit по kind использует индекс: < 50 мс на 10k', () => {
listAudit(db, { kind: 'logins', limit: 100 })
const { ms, value } = timed(() => listAudit(db, { kind: 'logins', limit: 100 }))
console.log(`listAudit(kind=logins): ${ms.toFixed(1)} мс`)
expect(value.length).toBe(100)
expect(ms).toBeLessThan(50)
})
it('10k горячих запросов профиля (prepared) < 400 мс', () => {
const id = users[0]!.id
const { ms } = timed(() => {
for (let i = 0; i < 10_000; i++) {
getUserById(db, id)
getUserApps(db, id)
}
})
console.log(`20k prepared-запросов: ${ms.toFixed(0)} мс`)
expect(ms).toBeLessThan(400)
})
it('поиск каталога по 200 пользователям < 20 мс', () => {
searchDirectoryUsers(db, 'user 1')
const { ms } = timed(() => searchDirectoryUsers(db, 'user 1'))
expect(ms).toBeLessThan(20)
})
})
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vitest/config'
// Бюджеты производительности БД (не входят в обычный test): pnpm --filter @authportal/db test:perf
export default defineConfig({
test: { include: ['test/perf/**/*.perf.ts'], testTimeout: 60_000 },
})
+4 -4
View File
@@ -26,13 +26,13 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "catalog:",
"@vitest/coverage-v8": "4.1.10", "@vitest/coverage-v8": "4.1.10",
"eslint": "^10.7.0", "eslint": "catalog:",
"globals": "^17.7.0", "globals": "catalog:",
"tsup": "^8.5.0", "tsup": "^8.5.0",
"typescript": "catalog:", "typescript": "catalog:",
"typescript-eslint": "^8.64.0", "typescript-eslint": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.6.0", "@base-ui/react": "catalog:",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "catalog:", "lucide-react": "catalog:",
+610 -41
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -10,3 +10,8 @@ catalog:
vitest: "^4.1.8" vitest: "^4.1.8"
zod: "^4.4.3" zod: "^4.4.3"
lucide-react: "^1.25.0" lucide-react: "^1.25.0"
eslint: "^10.7.0"
"@eslint/js": "^10.0.1"
globals: "^17.7.0"
typescript-eslint: "^8.64.0"
"@base-ui/react": "^1.6.0"
+73
View File
@@ -0,0 +1,73 @@
// Нагрузочный smoke по запущенному образу/серверу (autocannon).
// node scripts/perf/load.mjs http://127.0.0.1:8080 [container-name]
// Бюджеты (env): LOAD_MAX_P99_MS (150), LOAD_MIN_RPS (500), LOAD_MAX_RSS_MB (200), LOAD_DURATION_S (15)
import { execFileSync } from 'node:child_process'
import autocannon from 'autocannon'
const base = process.argv[2] ?? 'http://127.0.0.1:8080'
const container = process.argv[3]
const duration = Number(process.env.LOAD_DURATION_S ?? 15)
const maxP99 = Number(process.env.LOAD_MAX_P99_MS ?? 150)
const minRps = Number(process.env.LOAD_MIN_RPS ?? 500)
const maxRss = Number(process.env.LOAD_MAX_RSS_MB ?? 200)
const email = process.env.LOAD_EMAIL ?? 'admin@smoke.local'
const password = process.env.LOAD_PASSWORD
async function token() {
if (!password) return null
const res = await fetch(`${base}/api/v1/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error(`login ${res.status}`)
return (await res.json()).access_token
}
function run(title, opts) {
return new Promise((resolve, reject) => {
autocannon({ url: base, connections: 50, duration, ...opts }, (err, r) =>
err ? reject(err) : resolve({ title, r }),
)
})
}
function rssMb() {
if (!container) return null
const out = execFileSync('docker', ['stats', '--no-stream', '--format', '{{.MemUsage}}', container]).toString()
const m = /^([\d.]+)(KiB|MiB|GiB)/.exec(out.trim())
if (!m) return null
const v = Number(m[1])
return m[2] === 'GiB' ? v * 1024 : m[2] === 'KiB' ? v / 1024 : v
}
const jwt = await token()
const scenarios = [{ title: 'GET /health', opts: { url: `${base}/health` } }]
if (jwt) {
scenarios.push({
title: 'GET /api/v1/auth/me',
opts: { url: `${base}/api/v1/auth/me`, headers: { authorization: `Bearer ${jwt}` } },
})
}
const failures = []
for (const s of scenarios) {
const { r } = await run(s.title, s.opts)
const non2xx = r.non2xx
console.log(
`${s.title}: ${Math.round(r.requests.average)} rps, p99 ${r.latency.p99} ms, non2xx ${non2xx}, errors ${r.errors}`,
)
if (r.latency.p99 > maxP99) failures.push(`${s.title}: p99 ${r.latency.p99} > ${maxP99} ms`)
if (r.requests.average < minRps) failures.push(`${s.title}: ${Math.round(r.requests.average)} rps < ${minRps}`)
if (non2xx > 0 || r.errors > 0) failures.push(`${s.title}: ${non2xx} non2xx, ${r.errors} errors`)
}
const rss = rssMb()
if (rss != null) {
console.log(`RSS после нагрузки: ${Math.round(rss)} MiB`)
if (rss > maxRss) failures.push(`RSS ${Math.round(rss)} MiB > ${maxRss} MiB`)
}
if (failures.length) {
console.error('LOAD BUDGET FAILED:\n - ' + failures.join('\n - '))
process.exit(1)
}
console.log('LOAD OK')
+6 -1
View File
@@ -13,8 +13,13 @@
"dependsOn": ["^build"], "dependsOn": ["^build"],
"outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"] "outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"]
}, },
"test": {}, "test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "package.json", "tsconfig*.json", "vitest*.ts", "$TURBO_ROOT$/deploy/**", "$TURBO_ROOT$/docker-compose.yml", "$TURBO_ROOT$/scripts/ci/**"]
},
"test:coverage": { "test:coverage": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "package.json", "tsconfig*.json", "vitest*.ts", "$TURBO_ROOT$/deploy/**", "$TURBO_ROOT$/docker-compose.yml", "$TURBO_ROOT$/scripts/ci/**"],
"outputs": ["coverage/**"] "outputs": ["coverage/**"]
}, },
"lint": {} "lint": {}