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,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 }}-
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 [email protected] -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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:')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Открыть журнал входов
|
Открыть журнал входов
|
||||||
|
|||||||
@@ -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: '[email protected]',
|
||||||
|
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'
|
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'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
@@ -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 = выключен), число копий, каталог
|
||||||
|
|||||||
@@ -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
@@ -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": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "@node-rs/argon2"]
|
"onlyBuiltDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"@node-rs/argon2"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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, '[email protected]')
|
||||||
|
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('[email protected]')
|
||||||
|
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, '[email protected]')
|
||||||
|
expect(getUserById(a.db, ua.id)).toBeDefined()
|
||||||
|
expect(getUserById(b.db, ua.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
@@ -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:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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:",
|
||||||
|
|||||||
Generated
+610
-41
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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 ?? '[email protected]'
|
||||||
|
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
@@ -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": {}
|
||||||
|
|||||||
Reference in new issue
Block a user