17 Commits
Author SHA1 Message Date
DenozordecandClaude Sonnet 5.5 09c9531ad9 feat(ui): палитра токенов как в MikrotikManager (light/dark)
quality / changes (push) Successful in 7s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / web (push) Successful in 46s
quality / docker-check (push) Successful in 1m19s
CD / quality (push) Successful in 2m13s
CD / publish (push) Successful in 1m21s
packages/ui/src/styles/globals.css: значения :root/.dark перенесены из
MikrotikManager app/globals.css (background, card, primary, secondary, muted,
accent, destructive, border, input, ring, chart-1..5, sidebar-*, success/info/
warning/invert). Добавлены токены MM: chart-line-1..4, chart-rx/tx, status-*
(online/offline/degraded + fg/bg). Шкала радиусов -sm..-4xl как в MM
(множители от --radius 0.625rem). Имена токенов ReUI/shadcn сохранены,
--focus* оставлены (используются ReUI).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 18:11:29 +07:00
Denozordec c3e8ccf7c7 fix(ci): smoke — проверка 404 не должна падать из-за pipefail
quality / changes (push) Successful in 6s
quality / commitlint (push) Skipped
quality / web (push) Successful in 41s
quality / api (push) Successful in 48s
quality / docker-check (push) Successful in 1m6s
CD / quality (push) Successful in 2m44s
CD / publish (push) Successful in 1m54s
2026-10-23 17:57:42 +07:00
Denozordec 7cec86400b fix(ci): восстановить nightly-perf.yaml (контейнерная нагрузка, RSS отдельным шагом)
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 53s
quality / docker-check (push) Failing after 27s
CD / quality (push) Failing after 2m14s
CD / publish (push) Skipped
2026-10-23 17:54:09 +07:00
Denozordec eb48c83c7e fix(ci): smoke и nightly обращаются к контейнеру изнутри (раннер сам в контейнере)
nightly-perf.yaml (push) Invalid workflow file
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / docker-check (push) Canceled after 0s
quality / api (push) Canceled after 0s
quality / web (push) Canceled after 25s
CD / quality (push) Canceled after 34s
CD / publish (push) Skipped
2026-10-23 17:53:51 +07:00
Denozordec 60645d5773 fix(docker): копировать весь dist API и отключить splitting в tsup (ERR_MODULE_NOT_FOUND на chunk)
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / commitlint (push) Skipped
quality / api (push) Successful in 52s
quality / docker-check (push) Failing after 1m7s
CD / quality (push) Failing after 2m8s
CD / publish (push) Skipped
2026-10-23 17:45:36 +07:00
Denozordec 395e37ea7a chore: @types/node через catalog (Node 22 во всём монорепо)
quality / changes (push) Successful in 8s
quality / commitlint (push) Skipped
quality / web (push) Successful in 57s
quality / api (push) Successful in 43s
quality / docker-check (push) Failing after 3m39s
CD / quality (push) Failing after 5m29s
CD / publish (push) Skipped
2026-10-23 17:24:24 +07:00
Denozordec 1a68597d48 perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy 2026-10-23 17:22:23 +07:00
Denozordec 08be310e6b fix(api): CORS через isReturnToAllowed, мягкая проверка JWT_SECRET, 404 для /assets, better-sqlite3 в api, healthcheck на /ready 2026-10-23 17:13:16 +07:00
Denozordec 5cbf4d29d7 ci: покрытие и пороги, тесты shared/api, smoke образа и бюджеты, документация 2026-10-23 17:08:36 +07:00
Denozordec 22dd6f9937 fix(api): защита секретов в production, redact логов, bootstrap без полного скана users 2026-10-23 17:03:45 +07:00
Denozordec 8cba7a5c41 feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление 2026-10-23 17:02:50 +07:00
Denozordec 8298454c63 perf(docker): бандл API, brotli-статика, wget-healthcheck, лимиты ресурсов, вендорные чанки web 2026-10-23 17:01:02 +07:00
Denozordec 1d7ea35a23 perf(api): кэш-заголовки статики, CORS по allowlist, раздельные health/ready, compress в Traefik 2026-10-23 16:55:59 +07:00
Denozordec 30d331c633 perf(api): кэш профиля и конфигурации, лимитер argon2, SHA-256 для client secret, trust proxy 2026-10-23 16:54:05 +07:00
DenozordecandClaude Sonnet 5.5 107eddba9e perf(db): pragma, транзакции, индексы, версия схемы, SQL-фильтры и очистка
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 15:56:25 +07:00
DenozordecandClaude Sonnet 5.5 140ffdb7b1 fix(api): graceful shutdown, единый обработчик ошибок и корректные 404
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 15:53:18 +07:00
DenozordecandClaude Sonnet 5.5 0948455d15 fix(web): привести таблицы к виду MikrotikManager — тулбар, сегменты, шапка и ячейки
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m12s
CD / publish (push) Successful in 1m31s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 14:43:46 +07:00
84 changed files with 5653 additions and 741 deletions

No files matched your search

+1
View File
@@ -42,6 +42,7 @@ package-lock.json
data
*.exe
**/*.test.ts
**/test/**
**/*.spec.ts
coverage
.coverage
+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:
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
IMAGE_TAG: latest
# provenance + SBOM как attestations образа (только при push в registry)
ATTEST: "true"
VERSION: ${{ steps.meta.outputs.version }}
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
SHA_FULL: ${{ github.sha }}
+60
View File
@@ -0,0 +1,60 @@
# Ночной нагрузочный 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
- 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 --user root --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 \
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
-e [email protected] -e ISSUER=http://127.0.0.1:8080 \
-e RATE_LIMIT_MAX=1000000 authportal:load
for i in $(seq 1 40); do
docker exec authportal-load wget -q -O /dev/null http://127.0.0.1:8080/ready && break
sleep 0.5
done
# Раннер сам работает в контейнере: нагрузку гоним из соседнего контейнера
# в сетевом namespace приложения (127.0.0.1:8080 — это приложение).
- name: Load test (autocannon в namespace приложения)
run: |
set -euxo pipefail
docker run --rm -i --network container:authportal-load \
-e LOAD_PASSWORD=load-admin-pass-1 -e LOAD_DURATION_S=15 \
node:22-alpine sh -c 'mkdir /w && cd /w && npm i --silent --no-audit --no-fund [email protected] && cat > load.mjs && node load.mjs http://127.0.0.1:8080' \
< scripts/perf/load.mjs
- name: RSS после нагрузки
run: |
set -euxo pipefail
rss=$(docker stats --no-stream --format '{{.MemUsage}}' authportal-load \
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
echo "RSS: ${rss} MiB (budget 200)"
[ "$rss" -le 200 ]
- name: Container logs on failure
if: failure()
run: docker logs --tail 100 authportal-load || true
+29 -74
View File
@@ -110,7 +110,7 @@ jobs:
while IFS= read -r f || [ -n "${f:-}" ]; do
[ -z "${f:-}" ] && continue
case "$f" in
.gitea/workflows/*|scripts/*)
.gitea/workflows/*|.gitea/actions/*|scripts/*)
full_pipeline=true
;;
.cursor/*|.claude/*|.codegraph/*|.agents/*)
@@ -160,38 +160,19 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
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 }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: pnpm install, lint, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm --filter web lint
pnpm exec turbo run typecheck --filter=web
pnpm exec turbo run build --filter=web
pnpm --filter web test
node scripts/ci/web-budget.mjs apps/web/dist/assets
api:
needs: [changes]
@@ -200,37 +181,16 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
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 }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: pnpm install, lint, test, typecheck, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run test --filter=@authportal/api
pnpm exec turbo run test:coverage --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
pnpm exec turbo run typecheck --filter=@authportal/api
pnpm exec turbo run build --filter=@authportal/api
@@ -242,33 +202,13 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
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 }}-
- id: setup
uses: ./.gitea/actions/setup-pnpm
- name: Lint commit messages
env:
BASE_SHA: ${{ inputs.base_sha }}
HEAD_SHA: ${{ inputs.head_sha }}
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
@@ -276,7 +216,9 @@ jobs:
docker-check:
needs: [changes]
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
# Образ собирается из api/web/deploy: проверяем и на push в main (до публикации),
# чтобы сломанный Dockerfile не обнаруживался уже после создания тега релиза.
if: needs.changes.outputs.docker == 'true' || needs.changes.outputs.api == 'true' || needs.changes.outputs.web == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
@@ -310,3 +252,16 @@ jobs:
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache"
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
- name: build image + smoke test (ресурсы, graceful stop, сохранность данных)
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:smoke' authportal
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
+2
View File
@@ -36,3 +36,5 @@ version.txt
.release-version
CHANGELOG.md
.claude/settings.local.json
coverage
+11 -9
View File
@@ -5,38 +5,40 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"build": "tsup",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests"
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^22.15.32",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"@eslint/js": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+165 -36
View File
@@ -1,9 +1,9 @@
import type { FastifyInstance } from 'fastify'
import type { FastifyError, FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
@@ -14,8 +14,12 @@ import {
allPermissionKeys,
} from '@authportal/shared'
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
@@ -23,9 +27,13 @@ import {
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hash } from '@node-rs/argon2'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import type { AppConfig } from './config.js'
import {
assertBootstrapPassword,
parseTrustProxy,
type AppConfig,
} from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
@@ -33,7 +41,22 @@ import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
function boolFlag(v: string): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
declare module 'fastify' {
interface FastifyInstance {
@@ -43,11 +66,11 @@ declare module 'fastify' {
}
}
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword)
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
@@ -69,8 +92,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
}
// Ensure existing admins have app tiles if access was never set
for (const user of existing) {
if (!user.isAdmin) continue
for (const user of listAdmins(app.db)) {
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
@@ -91,32 +113,112 @@ export async function buildApp(opts: {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
logger: {
level: config.logLevel,
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
],
censor: '[redacted]',
},
},
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
connectionTimeout: 0,
requestTimeout: 30_000,
bodyLimit: 1_048_576,
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
await app.register(sensible)
await app.register(formbody)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true }
app.addHook('onClose', async () => {
closeDb(sqlite)
})
app.get('/ready', async () => {
healthCheck(sqlite)
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
const status =
err.name === 'ZodError'
? 400
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
? (err as FastifyError).statusCode!
: 500
if (status >= 500) {
req.log.error(err)
return reply.status(500).send({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
}
const code =
status === 429
? 'RATE_LIMITED'
: status === 400
? 'BAD_REQUEST'
: ((err as FastifyError).code ?? 'ERROR')
return reply.status(status).send({ error: { code, message: err.message } })
})
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
if (boolFlag(config.enableCompress)) {
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
}
await app.register(formbody)
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, {
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
await app.register(rateLimit, {
max: config.rateLimitMax,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
})
await app.register(fjwt, { secret: config.jwtSecret })
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
// контейнер из-за временной блокировки.
app.get('/health', async () => ({ ok: true }))
// readiness: БД отвечает и ключ подписи OIDC загружен.
app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
return { ok: true }
})
@@ -134,28 +236,55 @@ export async function buildApp(opts: {
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
preCompressed: true,
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
})
}
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
app.setNotFoundHandler((req, reply) => {
if (
spa &&
req.method === 'GET' &&
!API_PREFIXES.some((p) => req.url.startsWith(p))
) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
return app
}
+77 -1
View File
@@ -5,6 +5,17 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
return v === '1' || v.toLowerCase() === 'true'
}
/** Loopback + приватные сети (Docker/Traefik). */
export const DEFAULT_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'
export function parseTrustProxy(raw: string): boolean | string[] {
const v = raw.trim()
if (v === 'true') return true
if (v === 'false' || v === '') return false
return v.split(',').map((s) => s.trim()).filter(Boolean)
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
@@ -22,6 +33,26 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
enableCompress: z.string().default('false'),
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
rateLimitMax: z.coerce.number().int().positive().default(200),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
webauthnRpID: z.string().min(1),
@@ -31,6 +62,39 @@ export const configSchema = z.object({
export type AppConfig = z.infer<typeof configSchema>
const WEAK_JWT_SECRETS = new Set([
'change-me-in-production',
'dev-secret-change-me',
'secret',
'changeme',
])
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
throw new Error(
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
)
}
if (config.jwtSecret.length < 16) {
// Не роняем старые установки с коротким, но уникальным секретом.
process.emitWarning(
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
)
}
}
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
export function assertBootstrapPassword(config: AppConfig): void {
if (!config.isProd) return
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
throw new Error(
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
)
}
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
@@ -40,7 +104,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
return configSchema.parse({
const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
@@ -56,6 +120,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
@@ -64,6 +138,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(
+1 -4
View File
@@ -5,10 +5,7 @@ import {
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
return request.ip ?? null
}
+51
View File
@@ -0,0 +1,51 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
+27
View File
@@ -0,0 +1,27 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+8 -5
View File
@@ -12,17 +12,20 @@ import {
type UserRow,
} from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken(
app: FastifyInstance,
user: UserRow,
): LoginResponse {
const apps = getUserApps(app.db, user.id)
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id))
if (user.isAdmin) {
permissions = allPermissionKeys()
}
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
const profile = getUserProfile(app.db, user.id)
const apps = profile?.apps ?? getUserApps(app.db, user.id)
const permissions = user.isAdmin
? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions)
+22
View File
@@ -113,10 +113,16 @@ export async function ensureOidcSigningKey(
return cached
}
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
jwksCache = new WeakMap()
}
export function buildOidcDiscovery(config: AppConfig) {
@@ -157,6 +163,17 @@ export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const hit = jwksCache.get(app.db)
if (hit && hit.kid === cached?.kid) return hit.jwks
const jwks = computeJwks(app)
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
return jwks
}
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => {
try {
@@ -174,6 +191,11 @@ export async function buildJwks(
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
+58
View File
@@ -0,0 +1,58 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
* Возвращает функцию отписки и ручной триггер (для тестов).
*/
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
const exit = opts.exit ?? ((code: number) => process.exit(code))
const proc = opts.proc ?? process
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
let closing: Promise<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
if (closing) {
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
exit(1)
return closing
}
app.log.info(`shutdown: ${reason}`)
const timer = setTimeout(() => {
app.log.error('shutdown: timeout, forcing exit')
exit(1)
}, opts.timeoutMs)
timer.unref()
closing = app
.close()
.then(() => {
clearTimeout(timer)
exit(code)
})
.catch((err: unknown) => {
clearTimeout(timer)
app.log.error(err, 'shutdown: close failed')
exit(1)
})
return closing
}
const handlers = signals.map((sig) => {
const h = () => void shutdown(sig)
proc.on(sig, h)
return [sig, h] as const
})
const onFatal = (err: unknown) => {
app.log.error(err, 'fatal error')
void shutdown('fatal', 1)
}
proc.on('unhandledRejection', onFatal)
proc.on('uncaughtException', onFatal)
return {
shutdown,
dispose() {
for (const [sig, h] of handlers) proc.off(sig, h)
proc.off('unhandledRejection', onFatal)
proc.off('uncaughtException', onFatal)
},
}
}
+70
View File
@@ -0,0 +1,70 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+14 -27
View File
@@ -1,12 +1,8 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { UserRow } from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS, allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
export type AuthUser = {
id: string
@@ -67,24 +63,15 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
}
}
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
let permissions = normalizePermissionKeys(
getUserPermissions(request.server.db, user.id),
)
if (user.isAdmin) {
permissions = allPermissionKeys()
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
return {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
}
}
@@ -101,14 +88,14 @@ export async function requireAuth(
}
const sub = request.user.sub
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = loadAuthUser(request, row)
request.authUser = authUserFromProfile(profile)
}
export async function requireAdmin(
+16 -11
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
@@ -27,6 +28,7 @@ import {
setUserAccess,
updateOidcClient,
updateUser,
withTransaction,
} from '@authportal/db'
import {
APP_IDS,
@@ -168,14 +170,17 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const passwordHash = await hash(data.password)
const user = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
const passwordHash = await hashPassword(data.password)
const user = withTransaction(app.db, () => {
const created = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, created.id, data.apps, permissions)
return created
})
setUserAccess(app.db, user.id, data.apps, permissions)
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
@@ -218,7 +223,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
? await hashPassword(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
@@ -463,7 +468,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
@@ -545,7 +550,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
+24 -28
View File
@@ -1,5 +1,4 @@
import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
@@ -11,9 +10,11 @@ import {
getAppSwitcherConfig,
getUserByEmail,
getUserById,
listUsers,
searchDirectoryUsers,
revokeRefreshSession,
} from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
@@ -22,11 +23,14 @@ import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async () => ({
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}))
app.get('/api/v1/auth/config', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=60')
return {
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}
})
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
@@ -44,6 +48,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
@@ -63,7 +68,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const ok = await verify(user.passwordHash, password)
const ok = await verifyPassword(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
@@ -163,11 +168,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
app.post(
'/api/v1/auth/logout',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (request, reply) => {
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
}
reply.header(
'Set-Cookie',
@@ -205,9 +212,11 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
return { ok: true }
})
},
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
)
@@ -237,20 +246,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
)
.trim()
.toLowerCase()
const all = listUsers(app.db).filter((u) => !u.disabled)
const filtered = q
? all.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
u.name.toLowerCase().includes(q) ||
u.id.toLowerCase().includes(q),
)
: all
return filtered.slice(0, 50).map((u) => ({
id: u.id,
email: u.email,
name: u.name,
}))
return searchDirectoryUsers(app.db, q, 50)
},
)
+21 -25
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { appendAudit } from '@authportal/db'
import { appendAuditBatch } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
@@ -45,30 +45,26 @@ export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
})
}
let accepted = 0
let duplicates = 0
for (const event of parsed.data.events) {
try {
const inserted = appendAudit(app.db, {
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})
if (inserted) accepted += 1
else duplicates += 1
} catch (err) {
app.log.warn({ err, event_id: event.event_id }, 'ingest append failed')
}
const { accepted, duplicates, failed } = appendAuditBatch(
app.db,
parsed.data.events.map((event) => ({
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})),
)
if (failed > 0) {
app.log.warn({ failed }, 'ingest append failed for some events')
}
return { accepted, duplicates }
+23 -18
View File
@@ -1,7 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
@@ -19,6 +19,8 @@ import {
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import {
buildJwks,
buildOidcDiscovery,
@@ -33,15 +35,6 @@ const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function cookieValue(
cookieHeader: string | undefined,
name: string,
): string | null {
if (!cookieHeader) return null
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
return match?.[1] ? decodeURIComponent(match[1]) : null
}
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
@@ -60,14 +53,22 @@ function parseBasicAuth(
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
}
return res.ok
}
function redirectError(
@@ -94,7 +95,7 @@ function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
@@ -171,7 +172,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return buildJwks(app)
})
app.get('/oauth/authorize', async (request, reply) => {
app.get('/oauth/authorize', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
@@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(client, clientSecret)
const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
@@ -420,7 +423,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
},
})
app.get('/oauth/userinfo', async (request, reply) => {
app.get('/oauth/userinfo', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({
+21
View File
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
import { registerShutdown } from './lib/shutdown.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
@@ -29,8 +30,28 @@ for (const path of [
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify'
import { runHousekeeping } from '@authportal/db'
const HOUR_MS = 60 * 60 * 1000
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
export function startHousekeepingJob(app: FastifyInstance): () => void {
const run = () => {
try {
const res = runHousekeeping(app.db, {
retentionDays: app.config.sessionRetentionDays,
})
if (res.sessions + res.authCodes + res.challenges > 0) {
app.log.info(res, 'housekeeping purge')
}
} catch (err) {
app.log.warn({ err }, 'housekeeping job failed')
}
}
const initial = setTimeout(run, 30_000)
const interval = setInterval(run, HOUR_MS)
initial.unref()
interval.unref()
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = '[email protected]', password = 'adminpass') {
const res = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password },
})
return res
}
describe('auth', () => {
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
app = await buildTestApp()
const token = await adminToken(app)
expect((await login(app, '[email protected]', 'wrong')).statusCode).toBe(401)
expect((await login(app, '[email protected]', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Off',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const id = (created.json() as { id: string }).id
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { disabled: true },
})
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
})
it('me / reissue / catalog / logout по cookie', async () => {
app = await buildTestApp()
const res = await login(app)
const token = (res.json() as { access_token: string }).access_token
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
expect(me.statusCode).toBe(200)
expect((me.json() as { email: string }).email).toBe('[email protected]')
const reissue = await app.inject({
method: 'POST',
url: '/api/v1/auth/reissue',
headers: auth(token),
})
expect(reissue.statusCode).toBe(200)
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
expect(catalog.statusCode).toBe(200)
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
expect(noAuth.statusCode).toBe(401)
const logout = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
})
expect(logout.statusCode).toBe(200)
const revoked = app.sqlite
.prepare('SELECT revoked_at FROM refresh_sessions')
.get() as { revoked_at: string | null }
expect(revoked.revoked_at).toBeTruthy()
})
})
describe('admin users', () => {
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const payload = {
email: '[email protected]',
name: 'Dup',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
}
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(first.statusCode).toBe(201)
const id = (first.json() as { id: string }).id
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(dup.statusCode).toBe(409)
const bad = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: { email: 'nope' },
})
expect(bad.statusCode).toBe(400)
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { name: 'Renamed', password: 'newsecret12' },
})
expect(patched.statusCode).toBe(200)
expect((patched.json() as { name: string }).name).toBe('Renamed')
expect((await login(app, '[email protected]', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(del.statusCode).toBe(204)
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(gone.statusCode).toBe(404)
})
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
const myId = (me.json() as { id: string }).id
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
expect(self.statusCode).toBe(400)
await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, '[email protected]', 'secret12')).json() as { access_token: string }).access_token
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
expect(forbidden.statusCode).toBe(403)
})
})
describe('OIDC клиенты и обмен кода', () => {
async function setup(a: App) {
const token = await adminToken(a)
const created = await a.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: auth(token),
payload: {
name: 'RP',
redirect_uris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as { id: string; client_id: string; client_secret: string }
const loginRes = await login(a)
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
return { token, client, refresh }
}
async function authorizeCode(
a: App,
refresh: string,
clientId: string,
extra: Record<string, string> = {},
): Promise<string> {
const res = await a.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: clientId,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid profile',
...extra,
},
})
expect(res.statusCode).toBe(302)
return new URL(res.headers.location as string).searchParams.get('code')!
}
const exchange = (a: App, body: Record<string, string>) =>
a.inject({
method: 'POST',
url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({
grant_type: 'authorization_code',
redirect_uri: 'https://rp.test.local/cb',
...body,
}).toString(),
})
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const stored = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients')
.get() as { h: string }
expect(stored.h.startsWith('sha256:')).toBe(true)
const code = await authorizeCode(app, refresh, client.client_id)
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(ok.statusCode).toBe(200)
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
const replay = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(replay.statusCode).toBe(400)
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
})
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const verifier = 'v'.repeat(50)
const challenge = createHash('sha256').update(verifier).digest('base64url')
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
const creds = { client_id: client.client_id, client_secret: client.client_secret }
const good = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: verifier,
})
expect(good.statusCode).toBe(200)
const wrong = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: 'x'.repeat(50),
})
expect(wrong.statusCode).toBe(400)
const missing = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
})
expect(missing.statusCode).toBe(400)
const plain = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, {
code_challenge: 'plain-challenge',
code_challenge_method: 'plain',
}),
code_verifier: 'plain-challenge',
})
expect(plain.statusCode).toBe(200)
})
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const code = await authorizeCode(app, refresh, client.client_id)
const badSecret = await exchange(app, {
client_id: client.client_id,
client_secret: 'nope',
code,
})
expect(badSecret.statusCode).toBe(401)
const badRedirect = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://evil.test/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(badRedirect.statusCode).toBe(400)
const badScope = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid admin',
},
})
expect(badScope.statusCode).toBe(302)
expect(badScope.headers.location).toContain('error=invalid_scope')
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
const accessToken = (ok.json() as { access_token: string }).access_token
const info = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: auth(accessToken),
})
expect(info.statusCode).toBe(200)
expect((info.json() as { email: string }).email).toBe('[email protected]')
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
expect(noToken.statusCode).toBe(401)
})
it('без сессии authorize редиректит на логин', async () => {
app = await buildTestApp()
const { client } = await setup(app)
const res = await app.inject({
method: 'GET',
url: '/oauth/authorize',
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toContain('return_to=')
})
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
app = await buildTestApp()
const { refresh } = await setup(app)
const secret = generateOidcClientSecret()
const legacy = createOidcClient(app.db, {
name: 'Legacy',
redirectUris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile'],
enabled: true,
clientSecretHash: await hash(secret),
})
const code = await authorizeCode(app, refresh, legacy.clientId)
const res = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code,
})
expect(res.statusCode).toBe(200)
const row = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
.get(legacy.clientId) as { h: string }
expect(row.h.startsWith('sha256:')).toBe(true)
// после миграции секрет всё ещё работает
const code2 = await authorizeCode(app, refresh, legacy.clientId)
const again = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code: code2,
})
expect(again.statusCode).toBe(200)
})
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
app = await buildTestApp()
const { token, client, refresh } = await setup(app)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
payload: { name: 'Renamed RP' },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
const rotate = await app.inject({
method: 'POST',
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
headers: auth(token),
})
expect(rotate.statusCode).toBe(200)
const fresh = (rotate.json() as { client_secret: string }).client_secret
expect(fresh).not.toBe(client.client_secret)
const code = await authorizeCode(app, refresh, client.client_id)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
).toBe(401)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
).toBe(200)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(del.statusCode).toBe(200)
const gone = await app.inject({
method: 'GET',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(gone.statusCode).toBe(404)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: '[email protected]',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
import { buildApp } from '../src/app.js'
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
describe('loadConfig', () => {
it('значения по умолчанию для новых параметров', () => {
const c = loadConfig({ ...base, NODE_ENV: 'test' })
expect(c.shutdownTimeoutMs).toBe(8000)
expect(c.sessionRetentionDays).toBe(7)
expect(c.argon2Concurrency).toBe(2)
expect(c.backupKeep).toBe(7)
expect(c.backupIntervalHours).toBe(0)
expect(c.corsOrigins).toBe('')
expect(c.trustProxy).toContain('10.0.0.0/8')
})
it('читает переопределения из env', () => {
const c = loadConfig({
...base,
NODE_ENV: 'test',
SHUTDOWN_TIMEOUT_MS: '1500',
SESSION_RETENTION_DAYS: '30',
ARGON2_CONCURRENCY: '4',
BACKUP_KEEP: '3',
BACKUP_INTERVAL_HOURS: '12',
CORS_ORIGINS: 'https://a.example',
TRUST_PROXY: 'false',
})
expect(c).toMatchObject({
shutdownTimeoutMs: 1500,
sessionRetentionDays: 30,
argon2Concurrency: 4,
backupKeep: 3,
backupIntervalHours: 12,
corsOrigins: 'https://a.example',
trustProxy: 'false',
})
})
it.each([
['SHUTDOWN_TIMEOUT_MS', '0'],
['ARGON2_CONCURRENCY', '-1'],
['BACKUP_KEEP', 'abc'],
['BACKUP_INTERVAL_HOURS', '-5'],
])('невалидный %s=%s → ошибка', (key, value) => {
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
})
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
).toThrow(/JWT_SECRET/)
},
)
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
const prod = (pw?: string) =>
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
expect(() =>
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
).not.toThrow()
})
it('buildApp в production без нормального пароля падает при создании админа', async () => {
await expect(
buildApp({
config: loadConfig({
NODE_ENV: 'production',
...base,
ADMIN_PASSWORD: 'admin',
DATABASE_URL: 'sqlite::memory:',
}),
databaseUrl: 'sqlite::memory:',
}),
).rejects.toThrow(/ADMIN_PASSWORD/)
})
})
describe('redact в логах', () => {
let app: ReturnType<typeof Fastify> | undefined
afterEach(async () => {
await app?.close()
})
it('authorization и cookie не попадают в лог', async () => {
const lines: string[] = []
const stream = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk))
cb()
},
})
// Тот же набор путей, что и в buildApp.
app = Fastify({
logger: {
level: 'info',
stream,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
censor: '[redacted]',
},
serializers: {
req: (req) => ({ method: req.method, headers: req.headers }),
},
},
})
app.get('/x', async () => ({ ok: true }))
await app.inject({
method: 'GET',
url: '/x',
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
})
const joined = lines.join('')
expect(joined).toContain('[redacted]')
expect(joined).not.toContain('SECRET')
})
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adminToken, buildTestApp } from './helpers.js'
import { startHousekeepingJob } from '../src/services/housekeeping.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.useRealTimers()
await app?.close()
app = undefined
})
describe('directory/users', () => {
it('отдаёт только id/email/name и ограничивает 50', async () => {
app = await buildTestApp()
const token = await adminToken(app)
for (let i = 0; i < 55; i++) {
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES (?,?,?,?,0,0,'t','t')`,
)
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
}
const res = await app.inject({
method: 'GET',
url: '/api/v1/directory/users',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = res.json() as Array<Record<string, unknown>>
expect(body).toHaveLength(50)
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(res.body).not.toContain('hash')
const q = await app.inject({
method: 'GET',
url: '/api/v1/directory/users?q=U54',
headers: { authorization: `Bearer ${token}` },
})
expect((q.json() as unknown[]).length).toBe(1)
})
})
describe('ingest audit', () => {
const ev = (i: number) => ({
event_id: `ev-${i}`,
source_app: 'dns',
action: 'dns.test',
severity: 'info',
summary: 'x',
})
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
app = await buildTestApp()
const post = (events: unknown[]) =>
app!.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: { events },
})
const first = await post([ev(1), ev(2), ev(3)])
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
const second = await post([ev(3), ev(4)])
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
})
})
describe('housekeeping job', () => {
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
vi.useFakeTimers()
app = await buildTestApp()
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES ('uh','[email protected]','H','x',0,0,'t','t')`,
)
.run()
app.sqlite
.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
)
.run()
const stop = startHousekeepingJob(app)
await vi.advanceTimersByTimeAsync(31_000)
const left = app.sqlite
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
.get()
expect(left).toEqual({ c: 0 })
stop()
expect(vi.getTimerCount()).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../../..')
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
const dockerfile = read('deploy/docker/authportal/Dockerfile')
const composes = {
'docker-compose.yml': read('docker-compose.yml'),
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
}
describe('Dockerfile', () => {
it('graceful stop и ограничение ресурсов Node', () => {
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
})
it('healthcheck без запуска node', () => {
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
expect(hc).toContain('wget')
expect(hc).not.toContain('"node"')
})
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
expect(dockerfile).toMatch(/^USER node$/m)
const runtime = dockerfile.split('AS runtime')[1]!
expect(runtime).not.toMatch(/apk add/)
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
})
it('статика сжимается только brotli', () => {
expect(dockerfile).toContain('brotli')
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('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
})
describe.each(Object.entries(composes))('%s', (_name, text) => {
it('init, grace period и лимиты ресурсов', () => {
expect(text).toMatch(/init: true/)
expect(text).toMatch(/stop_grace_period: 15s/)
expect(text).toMatch(/mem_limit: 256m/)
expect(text).toMatch(/pids_limit: \d+/)
expect(text).toMatch(/read_only: true/)
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
expect(text).toMatch(/no-new-privileges:true/)
})
it('healthcheck через wget', () => {
expect(text).toMatch(/"wget", "-q"/)
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
})
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+277
View File
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hash } from '@node-rs/argon2'
import { TtlCache } from '../src/lib/cache.js'
import { parseCookie } from '../src/lib/cookies.js'
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
import {
hashPassword,
passwordHashStats,
setPasswordConcurrency,
verifyPassword,
} from '../src/lib/password.js'
import { parseTrustProxy } from '../src/config.js'
import { adminToken, buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
c.set('a', 1)
t = 99
expect(c.get('a')).toBe(1)
t = 100
expect(c.get('a')).toBeUndefined()
expect(c.size).toBe(0)
})
it('вытесняет давно не использованное (LRU)', () => {
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
c.set('a', 1)
c.set('b', 2)
c.get('a') // освежили a
c.set('c', 3)
expect(c.get('b')).toBeUndefined()
expect(c.get('a')).toBe(1)
expect(c.get('c')).toBe(3)
c.delete('a')
expect(c.get('a')).toBeUndefined()
c.clear()
expect(c.size).toBe(0)
})
})
describe('parseCookie', () => {
it('находит значение и декодирует', () => {
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
'abc d',
)
})
it('не путает суффикс имени', () => {
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
})
it('пусто/нет → null', () => {
expect(parseCookie(undefined, 'a')).toBeNull()
expect(parseCookie('a=; b=1', 'a')).toBeNull()
expect(parseCookie('b=1', 'a')).toBeNull()
})
})
describe('client secret', () => {
it('sha256-формат: верный/неверный, без rehash', async () => {
const stored = hashClientSecret('s3cret')
expect(stored.startsWith('sha256:')).toBe(true)
expect(await checkClientSecret(stored, 's3cret')).toEqual({
ok: true,
needsRehash: false,
})
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
})
it('argon2-формат принимается и помечается на rehash', async () => {
const legacy = await hash('old-secret')
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
ok: true,
needsRehash: true,
})
expect(await checkClientSecret(legacy, 'bad')).toEqual({
ok: false,
needsRehash: false,
})
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
})
})
describe('password limiter', () => {
it('не допускает больше N одновременных хешей', async () => {
setPasswordConcurrency(2)
let maxActive = 0
const poll = setInterval(() => {
maxActive = Math.max(maxActive, passwordHashStats().active)
}, 1)
const h = await hashPassword('pw')
await Promise.all(
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
)
clearInterval(poll)
expect(maxActive).toBeLessThanOrEqual(2)
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
setPasswordConcurrency(2)
})
})
describe('parseTrustProxy', () => {
it('true/false/список', () => {
expect(parseTrustProxy('true')).toBe(true)
expect(parseTrustProxy('false')).toBe(false)
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
})
})
async function createUserAndLogin(
a: NonNullable<typeof app>,
adminTok: string,
email: string,
) {
const create = await a.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${adminTok}` },
payload: {
email,
name: 'U',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const id = (create.json() as { id: string }).id
const login = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password: 'secret12' },
})
expect(login.statusCode).toBe(200)
return { id, token: (login.json() as { access_token: string }).access_token }
}
describe('кэш профиля пользователя', () => {
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200) // прогрев
const spy = vi.spyOn(app.sqlite, 'prepare')
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
const sqls = spy.mock.calls.map((c) => String(c[0]))
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
.toHaveLength(0)
})
it('блокировка пользователя действует немедленно', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
payload: { disabled: true },
})
expect(patch.statusCode).toBe(200)
expect((await me()).statusCode).toBe(401)
})
it('смена доступа отражается сразу, удаление → 401', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, '[email protected]')
const me = async () =>
(
await app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
).json() as { apps?: string[] }
expect((await me()).apps).toEqual(['cfdm'])
const put = await app.inject({
method: 'PUT',
url: `/api/v1/admin/users/${id}/access`,
headers: { authorization: `Bearer ${admin}` },
payload: { apps: ['dns'], permissions: [] },
})
expect(put.statusCode).toBe(200)
expect((await me()).apps).toEqual(['dns'])
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
})
const gone = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect(gone.statusCode).toBe(401)
})
})
describe('app-switcher кэш', () => {
it('PUT сразу виден в GET', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
const cfg = await app.inject({
method: 'GET',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${admin}` },
})
expect(before.statusCode).toBe(200)
expect(cfg.statusCode).toBe(200)
})
})
describe('rate-limit', () => {
it('/health не лимитируется', async () => {
app = await buildTestApp()
for (let i = 0; i < 250; i++) {
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
}
})
it('логин ограничен 20/мин', async () => {
app = await buildTestApp()
let last = 0
for (let i = 0; i < 22; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: '[email protected]', password: 'wrongpass' },
})
last = res.statusCode
}
expect(last).toBe(429)
})
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
remoteAddress: '203.0.113.9',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: '[email protected]', password: 'wrongpass' },
})
expect(res.statusCode).toBe(401)
const row = app.sqlite
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
.get() as { ip: string }
expect(row.ip).toBe('203.0.113.9')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
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:')
})
})
+120
View File
@@ -0,0 +1,120 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDb, closeDb, migrateSchema } from '@authportal/db'
import { registerShutdown } from '../src/lib/shutdown.js'
import { buildTestApp } from './helpers.js'
function fakeApp(close: () => Promise<void>) {
return {
close,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as Parameters<typeof registerShutdown>[0]
}
describe('registerShutdown', () => {
afterEach(() => {
vi.useRealTimers()
})
it('закрывает приложение один раз и выходит с 0', async () => {
const close = vi.fn(async () => {})
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
expect(close).toHaveBeenCalledTimes(1)
})
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
let release!: () => void
const close = vi.fn(
() => new Promise<void>((r) => (release = r)),
)
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
proc.emit('SIGINT')
expect(exit).toHaveBeenCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
release()
})
it('по таймауту принудительно выходит с кодом 1', async () => {
vi.useFakeTimers()
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(() => new Promise<void>(() => {})),
{ timeoutMs: 500, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.advanceTimersByTimeAsync(501)
expect(exit).toHaveBeenCalledWith(1)
})
it('ошибка close → выход с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(async () => {
throw new Error('boom')
}),
{ timeoutMs: 1000, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('unhandledRejection запускает shutdown с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('unhandledRejection', new Error('x'))
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('dispose снимает обработчики', () => {
const proc = new EventEmitter()
const h = registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit: vi.fn(),
proc: proc as never,
})
h.dispose()
expect(proc.listenerCount('SIGTERM')).toBe(0)
expect(proc.listenerCount('unhandledRejection')).toBe(0)
})
})
describe('closeDb', () => {
it('идемпотентно закрывает БД', () => {
const { sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
expect(() => sqlite.prepare('SELECT 1')).toThrow()
})
it('app.close() закрывает БД приложения', async () => {
const app = await buildTestApp()
await app.ready()
expect(app.sqlite.open).toBe(true)
await app.close()
expect(app.sqlite.open).toBe(false)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'tsup'
// Нативные модули остаются внешними (ставятся в образ отдельно),
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
export default defineConfig({
entry: ['src/server.ts'],
format: ['esm'],
target: 'node22',
external: NATIVE,
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
banner: {
js: [
"import { createRequire as __cr } from 'node:module';",
"import { fileURLToPath as __fu } from 'node:url';",
"import { dirname as __dn } from 'node:path';",
'const require = __cr(import.meta.url);',
'const __filename = __fu(import.meta.url);',
'const __dirname = __dn(__filename);',
].join('\n'),
},
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
splitting: false,
clean: true,
})
+8
View File
@@ -3,5 +3,13 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 75 },
},
},
})
+7 -8
View File
@@ -15,13 +15,11 @@
"dependencies": {
"@authportal/shared": "workspace:*",
"@authportal/ui": "workspace:*",
"@base-ui/react": "^1.5.0",
"@base-ui/react": "catalog:",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.4.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.1",
@@ -42,20 +40,21 @@
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@tanstack/router-cli": "^1.167.31",
"@types/node": "^24.12.3",
"@types/node": "catalog:",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint": "catalog:",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"globals": "catalog:",
"jsdom": "^30.1.3",
"rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.0.0",
"typescript": "catalog:",
"typescript-eslint": "^8.59.2",
"typescript-eslint": "catalog:",
"vite": "^8.0.12",
"vitest": "catalog:"
}
@@ -0,0 +1,26 @@
import type { DataGridFeatures, DataGridProps } from '@/components/reui/data-grid/data-grid'
export const DATA_GRID_CELL_PAD = 'py-3'
export const DATA_GRID_CELL_PAD_FIRST = 'pl-5 py-3'
export const DATA_GRID_CELL_PAD_LAST = 'pr-4 py-3'
export const DATA_GRID_LAYOUT: NonNullable<DataGridProps<DataGridFeatures, object>['tableLayout']> = {
rowBorder: true,
headerBackground: true,
headerBorder: true,
columnsResizable: false,
}
export const DATA_GRID_CLASS_NAMES: NonNullable<DataGridProps<DataGridFeatures, object>['tableClassNames']> = {
headerRow: 'border-b border-border',
bodyRow: 'group/row',
}
export const DATA_GRID_CONTAINER_CLASS = 'w-full rounded-none border-0'
/** Отступы ячейки по положению колонки: первая — pl-5, последняя — pr-4. */
export function cellPad(index: number, count: number) {
if (index === 0) return DATA_GRID_CELL_PAD_FIRST
if (index === count - 1) return DATA_GRID_CELL_PAD_LAST
return DATA_GRID_CELL_PAD
}
@@ -0,0 +1,123 @@
/**
* Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
* «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
*/
import type { ReactNode } from 'react'
import type { Column } from '@tanstack/react-table'
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, SearchIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { cn } from '@authportal/ui/lib/utils'
import type { DataGridFeatures } from '@/components/reui/data-grid/data-grid'
export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
return (
<Frame dense className={cn('w-full min-w-0', className)}>
<FramePanel className="flex w-full min-w-0 flex-col gap-0 overflow-hidden p-0 py-0 shadow-none">{children}</FramePanel>
</Frame>
)
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
label,
}: {
value: T
onChange: (value: T) => void
options: { value: T; label: string; count?: number }[]
label?: string
}) {
return (
<div role="group" aria-label={label} className="border-border bg-muted/40 flex w-fit items-center gap-1 rounded-md border p-0.5">
{options.map((o) => (
<button
key={o.value}
type="button"
aria-pressed={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
'flex items-center gap-1.5 rounded px-3 py-1 text-sm transition-colors',
value === o.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
{o.count != null ? <span className="text-xs tabular-nums">{o.count}</span> : null}
</button>
))}
</div>
)
}
export function DataPageToolbar({
leading,
segmented,
filterControl,
search,
onSearchChange,
searchPlaceholder = 'Поиск…',
countLabel,
actions,
}: {
leading?: ReactNode
segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
filterControl?: ReactNode
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
countLabel?: string
actions?: ReactNode
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
{leading}
{segmented ? <SegmentedControl {...segmented} /> : null}
{filterControl}
{onSearchChange ? (
<InputGroup className="max-w-sm min-w-[220px]">
<InputGroupAddon>
<SearchIcon className="size-3.5" aria-hidden />
</InputGroupAddon>
<InputGroupInput aria-label={searchPlaceholder} placeholder={searchPlaceholder} value={search ?? ''} onChange={(e) => onSearchChange(e.target.value)} />
</InputGroup>
) : null}
{countLabel ? <span className="text-muted-foreground ml-auto text-sm">{countLabel}</span> : null}
{actions}
</div>
)
}
export function DataGridSortHeader<TData extends object>({
column,
title,
className,
}: {
column: Column<DataGridFeatures, TData, unknown>
title: string
className?: string
}) {
const sorted = column.getIsSorted()
if (!column.getCanSort()) {
return <span className={cn('text-muted-foreground text-xs font-medium', className)}>{title}</span>
}
return (
<button
type="button"
className={cn(
'text-muted-foreground hover:text-foreground focus-visible:outline-ring -ml-1 inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2',
className,
)}
onClick={column.getToggleSortingHandler()}
>
{title}
{sorted === 'asc' ? (
<ArrowUpIcon aria-hidden className="text-foreground size-3" />
) : sorted === 'desc' ? (
<ArrowDownIcon aria-hidden className="text-foreground size-3" />
) : (
<ArrowUpDownIcon aria-hidden className="size-3 opacity-35" />
)}
</button>
)
}
@@ -51,7 +51,12 @@ export function ExplainBanner({
<Button
onClick={() => {
setOpen(false)
void navigate({ to: '/design-preview/logins' })
void navigate({
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
to: window.location.pathname.startsWith('/design-preview')
? '/design-preview/logins'
: '/admin/logins',
})
}}
>
Открыть журнал входов
+91 -100
View File
@@ -1,10 +1,9 @@
import { useMemo, useState, type ReactNode } from 'react'
import { Columns3Icon, MoreHorizontalIcon, SearchIcon } from 'lucide-react'
import { Columns3Icon, MoreHorizontalIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useTable, type ColumnDef, type RowSelectionState, type SortingState } from '@tanstack/react-table'
import { DATA_GRID_RU } from '@/lib/reui-ru'
import { DataGrid, dataGridFeatures, type DataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import {
@@ -17,7 +16,6 @@ import {
DataGridTableRowSelect,
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { Frame, FrameDescription, FrameFooter, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
import { Button } from '@authportal/ui/components/button'
import {
DropdownMenu,
@@ -26,12 +24,22 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@authportal/ui/components/dropdown-menu'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@authportal/ui/components/input-group'
import { Separator } from '@authportal/ui/components/separator'
import { Switch } from '@authportal/ui/components/switch'
import { Tabs, TabsList, TabsTrigger } from '@authportal/ui/components/tabs'
import { cn } from '@authportal/ui/lib/utils'
import { StatusBadge } from './bits'
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from './data-page-layout'
import {
DataGridSortHeader,
DataPageCard,
DataPageToolbar,
} from './data-page'
import type { Tone } from './tone'
import type { RowMark } from './tone'
import { TONE_DOT } from './tone'
@@ -101,8 +109,7 @@ export function SectionGrid<T extends object>({
rowMenu,
onReorder,
rowStatus,
pageSize = 8,
footer,
pageSize = 50,
emptyMessage = 'Нет записей по текущим фильтрам.',
className,
}: {
@@ -194,20 +201,22 @@ export function SectionGrid<T extends object>({
enableSorting: false,
enableResizing: false,
size: SELECT_COLUMN_SIZE,
meta: { headerClassName: 'pl-5 py-3', cellClassName: 'pl-5 py-3' },
})
}
for (const col of visibleColumns) {
visibleColumns.forEach((col, i) => {
const pad = i === 0 && !selectable && !onReorder ? DATA_GRID_CELL_PAD_FIRST : DATA_GRID_CELL_PAD
list.push({
id: col.id,
accessorFn: col.sortValue ?? (() => ''),
header: ({ column }) => <DataGridColumnHeader title={col.title} column={column} />,
header: ({ column }) => <DataGridSortHeader column={column} title={col.title} />,
cell: ({ row }) => col.cell(row.original),
enableSorting: Boolean(col.sortValue),
enableResizing: false,
size: col.size,
meta: { headerTitle: col.title },
meta: { headerTitle: col.title, headerClassName: pad, cellClassName: pad },
})
}
})
list.push({
id: '__actions',
header: () => <span className="sr-only">Действия</span>,
@@ -242,6 +251,7 @@ export function SectionGrid<T extends object>({
enableSorting: false,
enableResizing: false,
size: ACTIONS_COLUMN_SIZE,
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
})
return list
}, [visibleColumns, selectable, onReorder, rowSwitch, rowMenu, onRowClick])
@@ -277,6 +287,8 @@ export function SectionGrid<T extends object>({
const paged = data.length > pageSize
const showCards = Boolean(card) && cards
const countLabel = `${data.length} ${data.length === rows.length ? 'записей' : `из ${rows.length}`}`
return (
<DataGrid
table={table}
@@ -285,82 +297,72 @@ export function SectionGrid<T extends object>({
onRowClick={onRowClick}
getRowStatus={rowStatus}
emptyMessage={emptyMessage}
tableLayout={{ headerSticky: false, dense: false, width: 'auto', rowsDraggable: Boolean(onReorder) }}
tableLayout={{ ...DATA_GRID_LAYOUT, rowsDraggable: Boolean(onReorder) }}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame className={cn('[--frame-panel-header-py-adjust:4px] w-full min-w-0', className)}>
<FrameHeader className="flex-row flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-1 basis-52 flex-col gap-0.5">
<FrameTitle>{title}</FrameTitle>
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
</div>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : null}
{searchText ? (
<InputGroup className="w-[220px] max-w-full">
<InputGroupAddon>
<SearchIcon aria-hidden />
</InputGroupAddon>
<InputGroupInput
value={query}
placeholder="Поиск"
aria-label={searchPlaceholder}
onChange={(e) => {
setQuery(e.target.value)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
/>
</InputGroup>
) : null}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem
key={c.id}
checked={!hidden[c.id]}
onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}
>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</FrameHeader>
<FramePanel className="min-w-0 p-0 shadow-none">
{views ? (
<Tabs
value={String(view)}
onValueChange={(v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
}}
>
<TabsList variant="line" aria-label="Сохранённые виды" className="h-10! w-full justify-start gap-4 overflow-x-auto px-4">
{views.map((v, i) => (
<TabsTrigger key={v.label} value={String(i)} className="px-1 text-[13px]">
{v.label} · {rows.filter(v.match).length}
<DataPageCard className={className}>
<h2 className="sr-only">{title}</h2>
<DataPageToolbar
segmented={
views
? {
value: String(view),
label: description,
onChange: (v) => {
setView(Number(v))
setPagination((p) => ({ ...p, pageIndex: 0 }))
},
options: views.map((v, i) => ({ value: String(i), label: v.label, count: rows.filter(v.match).length })),
}
: undefined
}
leading={
card ? (
<Tabs value={cards ? 'cards' : 'table'} onValueChange={(v) => setCards(v === 'cards')} aria-label="Вид списка">
<TabsList className="h-9! gap-0.5 p-[3px]">
<TabsTrigger value="table" className="h-7! px-2.5 text-[12.5px]">
Таблица
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null}
<TabsTrigger value="cards" className="h-7! px-2.5 text-[12.5px]">
Карточки
</TabsTrigger>
</TabsList>
</Tabs>
) : undefined
}
search={query}
onSearchChange={
searchText
? (v) => {
setQuery(v)
setPagination((p) => ({ ...p, pageIndex: 0 }))
}
: undefined
}
searchPlaceholder={searchPlaceholder}
countLabel={countLabel}
actions={
<>
{typeof tools === 'function' ? tools(selectedIds) : tools}
{columns.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button type="button" variant="outline" size="icon" aria-label="Выбрать колонки" title="Колонки" />}
>
<Columns3Icon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{columns.slice(1).map((c) => (
<DropdownMenuCheckboxItem key={c.id} checked={!hidden[c.id]} onCheckedChange={(v) => setHidden((h) => ({ ...h, [c.id]: !v }))}>
{c.title}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</>
}
/>
{selectable && selectedIds.length > 0 ? (
<div
role="toolbar"
@@ -461,24 +463,13 @@ export function SectionGrid<T extends object>({
</ul>
) : (
<DataGridScrollArea>
<div className="pb-3" style={{ minWidth }}>
{onReorder ? (
<DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} />
) : (
<DataGridTable />
)}
<div style={{ minWidth }}>
{onReorder ? <DataGridTableDndRows dataIds={dataIds} handleDragEnd={handleDragEnd} /> : <DataGridTable />}
</div>
</DataGridScrollArea>
)}
<Separator />
<FrameFooter className="flex-wrap justify-between gap-2">
<span className="text-muted-foreground text-xs">
{footer ?? `Показано ${showCards ? data.length : table.getRowModel().rows.length} из ${data.length}`}
</span>
{paged && !showCards ? <DataGridPagination sizes={[8, 16, 32]} /> : null}
</FrameFooter>
</FramePanel>
</Frame>
{paged && !showCards ? <DataGridPagination className="px-5 py-3" sizes={[8, 16, 32]} /> : null}
</DataPageCard>
</DataGrid>
)
}
@@ -44,6 +44,13 @@ import {
DataGridTableRowSelectAll,
} from '@/components/reui/data-grid/data-grid-table'
import { DATA_GRID_RU, FILTERS_RU, FILTER_OPERATORS_RU } from '@/lib/reui-ru'
import { cellPad } from '@/components/portal/data-page-layout'
import {
DATA_GRID_CLASS_NAMES,
DATA_GRID_LAYOUT,
} from '@/components/portal/data-page-layout'
import { DataPageCard, DataPageToolbar } from '@/components/portal/data-page'
import { cn } from '@authportal/ui/lib/utils'
import { Filters } from '@/components/reui/filters/filters'
import {
createFilterQuery,
@@ -54,14 +61,6 @@ import type {
FilterField,
FilterQuery,
} from '@/components/reui/filters/filters-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
AlertDialog,
AlertDialogAction,
@@ -93,7 +92,6 @@ import {
SelectTrigger,
SelectValue,
} from '@authportal/ui/components/select'
import { Separator } from '@authportal/ui/components/separator'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
@@ -201,15 +199,6 @@ function formatLastActive(iso: string | null) {
}).format(date)
}
function DotSeparator() {
return (
<span
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
)
}
function RoleBadge({ isAdmin }: { isAdmin: boolean }) {
return isAdmin ? (
<Badge variant="info-outline">Админ</Badge>
@@ -710,15 +699,6 @@ export function AdminUsersGrid({
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
const lastAppliedKey = useRef<string>(serializeConditionsKey([]))
const stats = useMemo(
() => ({
total: users.length,
admins: users.filter((u) => u.is_admin).length,
disabled: users.filter((u) => u.disabled).length,
}),
[users],
)
const filterFields: FilterField[] = useMemo(
() => [
{
@@ -778,6 +758,16 @@ export function AdminUsersGrid({
onDeactivate,
onDelete,
onResetPasskeys,
}).map((col, i, all) => {
const pad = cellPad(i, all.length)
return {
...col,
meta: {
...col.meta,
headerClassName: cn(col.meta?.headerClassName, pad),
cellClassName: cn(col.meta?.cellClassName, pad),
},
}
}),
[onOpenAudit, onOpenAccess, onDeactivate, onDelete, onResetPasskeys],
)
@@ -850,38 +840,17 @@ export function AdminUsersGrid({
: undefined
}
tableLayout={{
...DATA_GRID_LAYOUT,
columnsResizable: true,
columnsMovable: true,
columnsVisibility: true,
headerSticky: true,
dense: true,
}}
tableClassNames={DATA_GRID_CLASS_NAMES}
>
<Frame spacing="sm" className="w-full">
<FrameHeader className="flex-row items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<FrameTitle id="page-heading" className="text-balance">
Пользователи
</FrameTitle>
<FrameDescription className="flex items-center gap-1.5 text-xs text-pretty">
<span>{stats.total} учётных записей</span>
<DotSeparator />
<span>{stats.admins} админов</span>
{stats.disabled > 0 ? (
<>
<DotSeparator />
<span>{stats.disabled} отключено</span>
</>
) : null}
</FrameDescription>
</div>
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</FrameHeader>
<FramePanel className="p-0 shadow-none">
<div className="flex flex-wrap items-center justify-between gap-2 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<DataPageCard>
<DataPageToolbar
filterControl={
<Filters
query={query}
fields={filterFields}
@@ -890,27 +859,18 @@ export function AdminUsersGrid({
operatorLabels={FILTER_OPERATORS_RU}
size="default"
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Фильтры"
>
<Button type="button" size="default" variant="outline" aria-label="Фильтры">
<FilterIcon aria-hidden />
Фильтры
</Button>
}
/>
<div className="flex flex-wrap items-center gap-2">
}
countLabel={`${filteredData.length} пользователей`}
actions={
<>
{showClearButton ? (
<Button
type="button"
size="default"
variant="outline"
className="shrink-0"
onClick={handleClearFilters}
disabled={gridLoading}
>
<Button type="button" size="default" variant="outline" className="shrink-0" onClick={handleClearFilters} disabled={gridLoading}>
<FunnelXIcon aria-hidden />
Сбросить
</Button>
@@ -918,42 +878,34 @@ export function AdminUsersGrid({
<DataGridColumnVisibility
table={table}
trigger={
<Button
type="button"
size="default"
variant="outline"
aria-label="Настройки таблицы"
>
<Button type="button" size="default" variant="outline" aria-label="Настройки таблицы">
<Settings2Icon aria-hidden="true" />
Колонки
</Button>
}
/>
</div>
</div>
<Separator />
{selectedCount > 0 ? (
<>
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
<Separator />
<Button type="button" size="default" onClick={onCreateClick}>
<UserPlusIcon aria-hidden="true" />
Создать
</Button>
</>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination sizes={[10, 20, 30]} />
</FrameFooter>
</FramePanel>
</Frame>
}
/>
{selectedCount > 0 ? (
<AdminUsersBulkActionBar
selectedCount={selectedCount}
roleValue={bulkRole}
onRoleChange={setBulkRole}
onChangeRole={handleChangeRole}
onDeactivate={handleBulkDeactivate}
onClear={handleClearSelection}
/>
) : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<DataGridPagination className="px-5 py-3" sizes={[10, 20, 30]} />
</DataPageCard>
</DataGrid>
</TooltipProvider>
)
+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: '[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')
})
})
+35
View File
@@ -0,0 +1,35 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
// @ts-expect-error — .mjs без типов
import { checkBudget } from '../../../scripts/ci/web-budget.mjs'
function dist(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'web-budget-'))
for (const [name, body] of Object.entries(files)) {
writeFileSync(join(dir, name), body)
}
return dir
}
describe('web-budget', () => {
it('в бюджете → без ошибок', () => {
const dir = dist({ 'index-abc.js': 'a'.repeat(100), 'x-1.js': 'b'.repeat(100) })
const { errors, sizes } = checkBudget(dir, { entryGz: 1000, totalGz: 2000 })
expect(errors).toEqual([])
expect(sizes.entryGz).toBeGreaterThan(0)
})
it('превышение entry и total → ошибки', () => {
const noise = Array.from({ length: 5000 }, (_, i) => `v${i}=${Math.random()}`).join(';')
const dir = dist({ 'index-abc.js': noise })
const { errors } = checkBudget(dir, { entryGz: 10, totalGz: 10 })
expect(errors).toHaveLength(2)
})
it('нет entry-чанка → ошибка', () => {
const dir = dist({ 'other.js': 'x' })
expect(checkBudget(dir).errors[0]).toMatch(/entry chunk/)
})
})
+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,
})
}
+16
View File
@@ -18,6 +18,22 @@ export default defineConfig(({ mode }) => ({
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
: []),
],
build: {
// Вендорные чанки меняются реже кода приложения → лучше кэш браузера между релизами.
rolldownOptions: {
output: {
advancedChunks: {
groups: [
{
name: 'vendor-react',
test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/,
},
{ name: 'vendor-tanstack', test: /node_modules[\\/]@tanstack[\\/]/ },
],
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+13
View File
@@ -1,7 +1,20 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@authportal/shared': path.resolve(
__dirname,
'../../packages/shared/src/index.ts',
),
},
},
test: {
passWithNoTests: true,
environment: 'jsdom',
include: ['test/**/*.test.ts'],
setupFiles: ['test/setup.ts'],
},
})
+28 -8
View File
@@ -29,6 +29,7 @@ services:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=auth-portal
- --ping=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
@@ -43,6 +44,12 @@ services:
- traefik_letsencrypt:/letsencrypt
networks:
- auth-portal
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
@@ -54,8 +61,23 @@ services:
pull_policy: always
container_name: auth-portal
restart: unless-stopped
init: true
stop_grace_period: 15s
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
mem_limit: 256m
memswap_limit: 256m
cpus: "1.0"
pids_limit: 128
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
depends_on:
- traefik
traefik:
condition: service_healthy
# No host ports — only Traefik publishes 80/443.
env_file:
- .env
@@ -86,14 +108,12 @@ services:
- traefik.http.routers.auth-portal.tls=true
- traefik.http.routers.auth-portal.tls.certresolver=letsencrypt
- traefik.http.services.auth-portal.loadbalancer.server.port=8080
# Сжатие JSON/текста на стороне Traefik (ноль CPU/RAM в Node);
# предсжатые .br/.gz статики Traefik не пересжимает (есть Content-Encoding).
- traefik.http.middlewares.auth-portal-compress.compress=true
- traefik.http.routers.auth-portal.middlewares=auth-portal-compress
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
interval: 30s
timeout: 5s
retries: 3
+32 -12
View File
@@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1.7
# 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
WORKDIR /repo
@@ -22,27 +24,43 @@ COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
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 в один файл (все зависимости внутри, кроме нативных).
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@authportal/api \
&& pnpm --filter @authportal/api deploy --prod /out \
pnpm turbo build --filter=web --filter=@authportal/api
# Рантайм-слой: бандл + статика (только brotli) + нативные модули с теми же версиями, что в lockfile.
RUN mkdir -p /out \
&& cp -r apps/api/dist /out/dist \
&& cp -r apps/web/dist /out/static \
&& find /out/static -type f \( -name '*.js' -o -name '*.css' \) -exec sh -c \
'gzip -k9 "$1" && brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
&& rm -rf /out/src /out/test /out/.turbo \
&& rm -rf /out/node_modules/@authportal/db/src /out/node_modules/@authportal/db/scripts /out/node_modules/@authportal/db/.turbo \
&& rm -rf /out/node_modules/@authportal/shared/src /out/node_modules/@authportal/shared/.turbo \
&& find /out/dist /out/node_modules/@authportal -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
&& find /out/static -type f \( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' \) \
-exec sh -c 'brotli -f -q 11 -o "$1.br" "$1"' _ {} \; \
&& node -e "const v=(p)=>require(p).version;require('fs').writeFileSync('/out/package.json',JSON.stringify({name:'authportal-runtime',private:true,type:'module',dependencies:{'better-sqlite3':v('./packages/db/node_modules/better-sqlite3/package.json'),'@node-rs/argon2':v('./apps/api/node_modules/@node-rs/argon2/package.json')}}))"
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
cd /out && npm install --omit=dev --no-audit --no-fund \
&& rm -rf node_modules/better-sqlite3/deps node_modules/better-sqlite3/src \
node_modules/better-sqlite3/build/Release/obj node_modules/better-sqlite3/build/Release/.deps \
node_modules/better-sqlite3/build/deps \
&& node -e "const D=require('better-sqlite3');new D(':memory:').prepare('select 1').get();import('@node-rs/argon2').then(m=>m.hash('x')).then(()=>console.log('native modules ok'))"
FROM ${BASE_NODE} AS runtime
WORKDIR /app
# Не-root runtime; /data — bind mount с хоста, на сервере нужен
# `chown -R 1000:1000 ./data` (см. deploy/docker-compose.traefik.yml).
RUN apk add --no-cache ca-certificates \
&& mkdir -p /data && chown node:node /data
RUN mkdir -p /data && chown node:node /data
ARG VERSION=dev
ARG GIT_SHA=unknown
ARG BUILD_TIME=
LABEL org.opencontainers.image.title="auth-portal" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_TIME}"
# Куча V8 ограничена под контейнер 256 МиБ (RSS = куча + нативная память + argon2);
# при другом mem_limit переопределите NODE_OPTIONS.
ENV NODE_ENV=production \
NODE_OPTIONS=--max-old-space-size=160 \
UV_THREADPOOL_SIZE=4 \
STATIC_DIR=/app/static \
DATABASE_URL=sqlite:/data/app.db \
SERVER_PORT=8080 \
@@ -52,7 +70,9 @@ ENV NODE_ENV=production \
COPY --from=build --chown=node:node /out ./
USER node
EXPOSE 8080
STOPSIGNAL SIGTERM
VOLUME ["/data"]
# busybox wget вместо запуска второго процесса node каждые 30 с.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
CMD ["node", "dist/server.js"]
+7 -1
View File
@@ -30,8 +30,13 @@ variable "CACHE_REF_NODE" {
default = ""
}
# provenance + SBOM; включается только при --push (docker exporter attestations не поддерживает).
variable "ATTEST" {
default = "false"
}
variable "BASE_NODE" {
default = "docker.io/library/node:22-alpine"
default = "docker.io/library/node:22-alpine@sha256:fe4f6d74e20370acb04ad889077d14d0f64a9c8ea6a42bc60cddf1e844699914"
}
function "node-cache-from" {
@@ -113,5 +118,6 @@ target "authportal" {
build = "target:node-build"
}
cache-from = node-cache-from()
attest = equal(ATTEST, "true") ? ["type=provenance,mode=min", "type=sbom"] : []
tags = concat(image-tags("authportal"), image-tags("auth-portal"))
}
+25
View File
@@ -31,3 +31,28 @@ NODE_ENV=production
# WEBAUTHN_RP_ID=auth.shnt.top
# WEBAUTHN_ORIGINS=https://auth.shnt.top
# WEBAUTHN_RP_NAME=Auth Portal
# --- Ingest аудита от подключённых приложений (обязателен для /api/v1/ingest/audit) ---
# AUDIT_INGEST_SECRET=
# --- Производительность / эксплуатация (все необязательны) ---
# Максимум на graceful shutdown, мс
# SHUTDOWN_TIMEOUT_MS=8000
# Одновременных argon2-хешей (каждый ~19 МиБ RAM)
# ARGON2_CONCURRENCY=2
# Сколько дней хранить истёкшие/отозванные сессии и коды
# SESSION_RETENTION_DAYS=7
# Доверенные прокси для 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
# Сжатие ответов в Node (нужно только без Traefik compress)
# ENABLE_COMPRESS=false
# Глобальный лимит запросов на IP в минуту
# RATE_LIMIT_MAX=200
# Доп. origins для CORS (по умолчанию: issuer, RETURN_TO_ALLOWLIST, приложения App Switcher)
# CORS_ORIGINS=
# Бэкап SQLite: период в часах (0 = выключен), число копий, каталог
# BACKUP_INTERVAL_HOURS=24
# BACKUP_KEEP=7
# BACKUP_DIR=/data/backups
# Heap Node (образ рассчитан на mem_limit 256m); при другом лимите:
# NODE_OPTIONS=--max-old-space-size=160
+17 -3
View File
@@ -6,7 +6,7 @@ services:
- "8080:8080"
environment:
DATABASE_URL: sqlite:/data/app.db
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env (min 16 chars)}
JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1}
REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14}
ISSUER: ${ISSUER:-https://auth.shnt.top}
@@ -15,7 +15,7 @@ services:
# Опционально: PKCS8 PEM RSA. Иначе ключ генерируется и хранится в SQLite (/data).
OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-}
ADMIN_EMAIL: ${ADMIN_EMAIL:[email protected]}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env (min 8 chars)}
ADMIN_NAME: ${ADMIN_NAME:-Admin}
RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top,localhost,private}
LOG_LEVEL: info
@@ -23,8 +23,22 @@ services:
volumes:
- ./data:/data
restart: unless-stopped
init: true
stop_grace_period: 15s
# Лимиты ресурсов: heap Node (NODE_OPTIONS в образе) рассчитан на 256 МиБ.
mem_limit: 256m
memswap_limit: 256m
cpus: "1.0"
pids_limit: 128
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/ready"]
interval: 30s
timeout: 5s
retries: 3
+12 -2
View File
@@ -211,14 +211,24 @@ Portal — IdP; Technitium — RP. После `docker compose up`:
## Бэкап
```bash
# SQLite
cp /opt/auth-portal/data/app.db /opt/auth-portal/data/app.db.bak-$(date +%F)
# SQLite: согласованная онлайн-копия (WAL учитывается, сервис не останавливается),
# проверка integrity_check и ротация (BACKUP_KEEP, по умолчанию 7) → /data/backups/
docker exec auth-portal node dist/server.js backup
# Либо встроенный планировщик: BACKUP_INTERVAL_HOURS=24 в .env (0 = выключен).
# Каталог: BACKUP_DIR (по умолчанию <каталог БД>/backups). Не копируйте app.db
# через `cp` на живой БД — без -wal/-shm копия может быть неполной.
# ACME
docker run --rm -v auth_portal_traefik_letsencrypt:/data -v "$PWD:/backup" alpine \
tar czf /backup/traefik-acme-$(date +%F).tgz -C /data .
```
**Восстановление:** `docker compose stop app`, затем в `./data` удалить
`app.db-wal`/`app.db-shm`, скопировать нужный `backups/app-*.db` как `app.db`,
`chown 1000:1000`, `docker compose up -d`. OIDC-ключ подписи хранится в этой же
БД, если не задан `OIDC_RSA_PRIVATE_KEY` — храните ключ отдельным секретом.
Откат образа: в `.env` `AUTH_IMAGE_TAG=vX.Y.Z` → `docker compose pull && docker compose up -d`.
---
+62
View File
@@ -0,0 +1,62 @@
# Производительность и ресурсы контейнера
Что сделано для быстродействия, стабильности и минимального потребления
ресурсов, и как это проверять.
## Что включено
| Область | Решение |
|---|---|
| Остановка | SIGTERM/SIGINT → `app.close()` (дожидается запросов) → WAL checkpoint → `close`. Таймаут `SHUTDOWN_TIMEOUT_MS` (8 с), повторный сигнал — немедленный выход. В compose `init: true`, `stop_grace_period: 15s`. |
| SQLite | `busy_timeout`, `cache_size`, `temp_store=MEMORY`, `mmap_size`, лимит WAL; транзакции для записи доступа/аудита; версия схемы в `user_version` (миграции не гоняются на каждом старте); индексы под очистку и фильтры; фильтры/сортировки в SQL. |
| Очистка | Раз в час: истёкшие/отозванные сессии, коды авторизации, WebAuthn-челленджи (пачками по 1000, `SESSION_RETENTION_DAYS`). |
| Горячий путь | Кэш профиля пользователя (инвалидируется любой записью в users/доступ — блокировка действует сразу), кэш App Switcher, мемоизация JWKS. Лимитер argon2 (`ARGON2_CONCURRENCY`). Секреты OIDC-клиентов — SHA-256 (старые argon2 мигрируют при первом обмене). |
| HTTP | Hashed-ассеты `immutable` на год, `index.html` — `no-cache`; сжатие JSON на стороне Traefik; CORS по allowlist; `/health` (liveness, без БД) и `/ready` (БД + ключ OIDC); корректные 404 для `/oauth`, `/.well-known`, `/api`. |
| Образ | 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`, ротация. |
Дополнительно: подготовленные запросы (`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
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
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`,
`MAX_STOP_S=5`. Подтяните их после первого замера на CI.
## Эксплуатационные заметки
- В production `JWT_SECRET` должен быть уникальным (≥ 16 символов), а
`ADMIN_PASSWORD` для первого запуска — не короче 8 символов и не `admin`.
- Если нужен другой лимит памяти, меняйте `mem_limit` и `NODE_OPTIONS` вместе
(куча ≈ 60–65 % лимита).
- Одна реплика: SQLite не поддерживает несколько писателей.
+6 -1
View File
@@ -16,12 +16,17 @@
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.0.0",
"@semantic-release/release-notes-generator": "^14.0.3",
"autocannon": "7.15.0",
"conventional-commits-parser": "^6.4.0",
"semantic-release": "^25.0.2",
"turbo": "^2.5.4"
},
"packageManager": "[email protected]",
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild", "@node-rs/argon2"]
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"@node-rs/argon2"
]
}
}
+9 -4
View File
@@ -19,6 +19,9 @@
"build": "tsup src/index.ts --format esm --dts",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"test:perf": "vitest run --config vitest.perf.config.ts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
@@ -29,13 +32,15 @@
"drizzle-orm": "^0.44.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "4.1.10",
"drizzle-kit": "^0.31.1",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0"
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+35 -14
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm'
import { and, desc, eq, gte, lt, or, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import {
DEFAULT_AUDIT_RETENTION_DAYS,
@@ -10,6 +10,7 @@ import {
} from '@authportal/shared'
import type { AppDb } from './index.js'
import { auditLog, portalSettings } from './schema/index.js'
import { withTransaction } from './tx.js'
const SETTINGS_ID = 'main'
@@ -64,16 +65,9 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
const now = input.createdAt ?? new Date().toISOString()
const eventId = input.eventId ?? null
if (eventId) {
const existing = db
.select({ id: auditLog.id })
.from(auditLog)
.where(eq(auditLog.eventId, eventId))
.get()
if (existing) return false
}
db.insert(auditLog)
// Дубликат event_id отсекается уникальным индексом: без SELECT и гонок.
const res = db
.insert(auditLog)
.values({
id: randomUUID(),
eventId,
@@ -90,8 +84,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
ip: input.ip ?? null,
createdAt: now,
})
.onConflictDoNothing()
.run()
return true
return res.changes > 0
}
/** Пачка событий в одной транзакции — один коммит вместо N. */
export function appendAuditBatch(
db: AppDb,
inputs: AppendAuditInput[],
): { accepted: number; duplicates: number; failed: number } {
let accepted = 0
let duplicates = 0
let failed = 0
withTransaction(db, () => {
for (const input of inputs) {
try {
// SAVEPOINT: сбой одного события не откатывает остальные.
const ok = withTransaction(db, () => appendAudit(db, input))
if (ok) accepted += 1
else duplicates += 1
} catch {
failed += 1
}
}
})
return { accepted, duplicates, failed }
}
export function listAudit(
@@ -112,9 +130,12 @@ export function listAudit(
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
if (opts.kind === 'logins') {
conditions.push(like(auditLog.action, 'auth.%'))
// Диапазон по индексу action: 'auth.' <= action < 'auth/' ('/' идёт сразу после '.').
conditions.push(gte(auditLog.action, 'auth.'), lt(auditLog.action, 'auth/'))
} else if (opts.kind === 'changes') {
conditions.push(notLike(auditLog.action, 'auth.%'))
conditions.push(
or(lt(auditLog.action, 'auth.'), gte(auditLog.action, 'auth/'))!,
)
}
if (opts.actorEmail && opts.userId) {
conditions.push(
+66
View File
@@ -0,0 +1,66 @@
import { mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import type { Sqlite } from './index.js'
const BACKUP_RE = /^app-\d{8}-\d{6}-\d{3}\.db$/
function stamp(d: Date): string {
const p = (n: number, w = 2) => String(n).padStart(w, '0')
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}` +
`-${p(d.getUTCMilliseconds(), 3)}`
)
}
/** PRAGMA integrity_check для файла БД; true, если результат 'ok'. */
export function checkIntegrity(path: string): boolean {
const db = new Database(path, { readonly: true, fileMustExist: true })
try {
return db.pragma('integrity_check', { simple: true }) === 'ok'
} finally {
db.close()
}
}
export function listBackups(dir: string): string[] {
try {
return readdirSync(dir)
.filter((f) => BACKUP_RE.test(f))
.sort()
.reverse()
} catch {
return []
}
}
/**
* Согласованный онлайн-бэкап (better-sqlite3 backup API работает с WAL без
* остановки сервиса), проверка integrity_check, ротация по числу копий.
*/
export async function backupDatabase(
sqlite: Sqlite,
opts: { dir: string; keep: number; now?: Date },
): Promise<{ file: string; removed: string[] }> {
mkdirSync(opts.dir, { recursive: true })
const name = `app-${stamp(opts.now ?? new Date())}.db`
const finalPath = join(opts.dir, name)
const partial = `${finalPath}.partial`
try {
await sqlite.backup(partial)
if (!checkIntegrity(partial)) {
throw new Error('backup integrity_check failed')
}
renameSync(partial, finalPath)
} catch (err) {
rmSync(partial, { force: true })
throw err
}
const removed: string[] = []
for (const old of listBackups(opts.dir).slice(Math.max(1, opts.keep))) {
rmSync(join(opts.dir, old), { force: true })
removed.push(old)
}
return { file: finalPath, removed }
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Счётчик «поколения» данных пользователей: растёт при любой записи в
* users/user_apps/user_permissions через функции этого пакета. Кэши верхнего
* уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу.
*/
let userDataGeneration = 0
export function getUserDataGeneration(): number {
return userDataGeneration
}
export function bumpUserDataGeneration(): void {
userDataGeneration += 1
}
+69
View File
@@ -0,0 +1,69 @@
import { sql } from 'drizzle-orm'
import type { AppDb } from './index.js'
const BATCH = 1000
/** Удаляет пачками по BATCH строк, чтобы не держать писательскую блокировку надолго. */
function deleteInBatches(
db: AppDb,
table: string,
where: ReturnType<typeof sql>,
): number {
let total = 0
for (;;) {
const res = db.run(
sql`DELETE FROM ${sql.raw(table)} WHERE rowid IN (
SELECT rowid FROM ${sql.raw(table)} WHERE ${where} LIMIT ${BATCH}
)`,
)
total += res.changes
if (res.changes < BATCH) return total
}
}
/** Сессии, истёкшие или отозванные раньше cutoff (ISO). */
export function purgeStaleRefreshSessions(db: AppDb, cutoffIso: string): number {
return deleteInBatches(
db,
'refresh_sessions',
sql`expires_at < ${cutoffIso} OR (revoked_at IS NOT NULL AND revoked_at < ${cutoffIso})`,
)
}
/** Коды авторизации: истёкшие раньше cutoff, либо использованные раньше cutoff. */
export function purgeStaleOidcAuthCodes(db: AppDb, cutoffIso: string): number {
return deleteInBatches(
db,
'oidc_auth_codes',
sql`expires_at < ${cutoffIso} OR (used_at IS NOT NULL AND used_at < ${cutoffIso})`,
)
}
export function purgeExpiredChallenges(db: AppDb, nowIso: string): number {
return deleteInBatches(db, 'webauthn_challenges', sql`expires_at < ${nowIso}`)
}
export type HousekeepingResult = {
sessions: number
authCodes: number
challenges: number
}
/**
* Одна проходка очистки. Записи хранятся ещё retentionDays после
* истечения/отзыва — для разбора инцидентов в списке сессий.
*/
export function runHousekeeping(
db: AppDb,
opts: { retentionDays: number; now?: Date } = { retentionDays: 7 },
): HousekeepingResult {
const now = opts.now ?? new Date()
const cutoff = new Date(
now.getTime() - opts.retentionDays * 24 * 60 * 60 * 1000,
).toISOString()
return {
sessions: purgeStaleRefreshSessions(db, cutoff),
authCodes: purgeStaleOidcAuthCodes(db, cutoff),
challenges: purgeExpiredChallenges(db, now.toISOString()),
}
}
+55
View File
@@ -17,6 +17,13 @@ export function createDb(databaseUrl: string): { db: AppDb; sqlite: Sqlite } {
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.pragma('synchronous = NORMAL')
// Без busy_timeout любая конкуренция за блокировку сразу даёт SQLITE_BUSY.
sqlite.pragma('busy_timeout = 5000')
sqlite.pragma('cache_size = -16000') // ~16 МиБ
sqlite.pragma('temp_store = MEMORY')
sqlite.pragma('mmap_size = 67108864') // 64 МиБ
sqlite.pragma('wal_autocheckpoint = 1000')
sqlite.pragma('journal_size_limit = 67108864')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
@@ -28,7 +35,40 @@ export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } {
return { db, sqlite }
}
/** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */
// ВАЖНО: любое изменение baseMigrate()/migrateV1() требует увеличить SCHEMA_VERSION
// (+ новая migrateVN), иначе существующие БД со скипом по user_version его не применят.
export const SCHEMA_VERSION = 1
export function migrateSchema(sqlite: Sqlite): void {
const current = sqlite.pragma('user_version', { simple: true }) as number
if (current >= SCHEMA_VERSION) return
sqlite.transaction(() => {
baseMigrate(sqlite)
migrateV1(sqlite)
sqlite.pragma(`user_version = ${SCHEMA_VERSION}`)
})()
}
/** v1: дедупликация доступа, уникальные индексы и индексы под очистку/фильтры. */
function migrateV1(sqlite: Sqlite): void {
sqlite.exec(`
DELETE FROM user_apps WHERE rowid NOT IN (
SELECT MIN(rowid) FROM user_apps GROUP BY user_id, app_id
);
DELETE FROM user_permissions WHERE rowid NOT IN (
SELECT MIN(rowid) FROM user_permissions GROUP BY user_id, permission
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_apps ON user_apps(user_id, app_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_permissions ON user_permissions(user_id, permission);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires ON refresh_sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_severity ON audit_log(severity, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_email ON audit_log(actor_email);
`)
}
function baseMigrate(sqlite: Sqlite): void {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
@@ -215,11 +255,26 @@ export function migrateSchema(sqlite: Sqlite): void {
`)
}
/** Идемпотентное закрытие: checkpoint WAL, обновление статистики планировщика, close. */
export function closeDb(sqlite: Sqlite): void {
if (!sqlite.open) return
try {
if (!sqlite.memory) sqlite.pragma('wal_checkpoint(TRUNCATE)')
sqlite.pragma('optimize')
} finally {
sqlite.close()
}
}
export function healthCheck(sqlite: Sqlite): void {
sqlite.prepare('SELECT 1').get()
}
export * from './schema/index.js'
export * from './tx.js'
export * from './backup.js'
export * from './generation.js'
export * from './housekeeping.js'
export * from './users.js'
export * from './settings.js'
export * from './audit-log.js'
+32 -34
View File
@@ -1,6 +1,7 @@
import { and, eq, isNull } from 'drizzle-orm'
import { and, desc, eq, gte, isNull } from 'drizzle-orm'
import { randomBytes, randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import { withTransaction } from './tx.js'
import { oidcAuthCodes, oidcClients, oidcSigningKeys } from './schema/index.js'
import { hashToken } from './users.js'
@@ -153,22 +154,20 @@ export function consumeOidcAuthCode(
db: AppDb,
rawCode: string,
): OidcAuthCodeRow | undefined {
const codeHash = hashToken(rawCode)
const row = db
.select()
.from(oidcAuthCodes)
.where(
and(eq(oidcAuthCodes.codeHash, codeHash), isNull(oidcAuthCodes.usedAt)),
)
.get()
if (!row) return undefined
const now = new Date().toISOString()
if (row.expiresAt < now) return undefined
db.update(oidcAuthCodes)
// Атомарно: UPDATE … RETURNING исключает двойное использование кода.
return db
.update(oidcAuthCodes)
.set({ usedAt: now })
.where(eq(oidcAuthCodes.id, row.id))
.run()
return row
.where(
and(
eq(oidcAuthCodes.codeHash, hashToken(rawCode)),
isNull(oidcAuthCodes.usedAt),
gte(oidcAuthCodes.expiresAt, now),
),
)
.returning()
.get()
}
export function getActiveOidcSigningKey(
@@ -178,8 +177,9 @@ export function getActiveOidcSigningKey(
.select()
.from(oidcSigningKeys)
.where(eq(oidcSigningKeys.active, true))
.all()
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0]
.orderBy(desc(oidcSigningKeys.createdAt))
.limit(1)
.get()
}
export function listOidcSigningKeys(db: AppDb): OidcSigningKeyRow[] {
@@ -194,23 +194,21 @@ export function insertOidcSigningKey(
publicJwkJson: string
},
): OidcSigningKeyRow {
for (const key of listOidcSigningKeys(db)) {
if (key.active) {
db.update(oidcSigningKeys)
.set({ active: false })
.where(eq(oidcSigningKeys.kid, key.kid))
.run()
}
}
db.insert(oidcSigningKeys)
.values({
kid: input.kid,
privatePem: input.privatePem,
publicJwkJson: input.publicJwkJson,
active: true,
createdAt: new Date().toISOString(),
})
.run()
withTransaction(db, () => {
db.update(oidcSigningKeys)
.set({ active: false })
.where(eq(oidcSigningKeys.active, true))
.run()
db.insert(oidcSigningKeys)
.values({
kid: input.kid,
privatePem: input.privatePem,
publicJwkJson: input.publicJwkJson,
active: true,
createdAt: new Date().toISOString(),
})
.run()
})
return db
.select()
.from(oidcSigningKeys)
+18
View File
@@ -10,7 +10,24 @@ import { portalSettings } from './schema/index.js'
const SETTINGS_ID = 'main'
/** Разобранный конфиг на соединение; сбрасывается в setAppSwitcherConfig. */
const switcherCache = new WeakMap<AppDb, AppSwitcherConfig>()
export function getAppSwitcherConfig(db: AppDb): AppSwitcherConfig {
const hit = switcherCache.get(db)
// клон: вызывающий код вправе мутировать результат
if (hit) return structuredClone(hit)
const config = loadAppSwitcherConfig(db)
switcherCache.set(db, config)
return structuredClone(config)
}
/** Сброс кэша (если БД правили в обход setAppSwitcherConfig). */
export function invalidateAppSwitcherConfig(db: AppDb): void {
switcherCache.delete(db)
}
function loadAppSwitcherConfig(db: AppDb): AppSwitcherConfig {
const row = db
.select()
.from(portalSettings)
@@ -51,5 +68,6 @@ export function setAppSwitcherConfig(
})
.run()
}
switcherCache.delete(db)
return normalized
}
+9
View File
@@ -0,0 +1,9 @@
import type { AppDb } from './index.js'
/**
* Выполняет fn в транзакции better-sqlite3. Вложенные вызовы превращаются
* в SAVEPOINT, поэтому безопасно вызывать из функций, уже работающих в транзакции.
*/
export function withTransaction<T>(db: AppDb, fn: () => T): T {
return db.$client.transaction(fn)()
}
+121 -50
View File
@@ -1,6 +1,8 @@
import { and, eq, gt, isNull } from 'drizzle-orm'
import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm'
import { createHash, randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import { bumpUserDataGeneration } from './generation.js'
import { withTransaction } from './tx.js'
import {
refreshSessions,
userApps,
@@ -18,8 +20,57 @@ export function listUsers(db: AppDb): UserRow[] {
return db.select().from(users).all()
}
/** Есть ли хотя бы один пользователь (LIMIT 1 вместо загрузки всей таблицы). */
export function hasAnyUser(db: AppDb): boolean {
return db.select({ id: users.id }).from(users).limit(1).get() !== undefined
}
export function listAdmins(db: AppDb): UserRow[] {
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 {
return db.select().from(users).where(eq(users.id, id)).get()
return prepared(db).userById.get({ id })
}
export function getUserByEmail(
@@ -34,20 +85,14 @@ export function getUserByEmail(
}
export function getUserApps(db: AppDb, userId: string): string[] {
return db
.select()
.from(userApps)
.where(eq(userApps.userId, userId))
.all()
return prepared(db)
.apps.all({ userId })
.map((r) => r.appId)
}
export function getUserPermissions(db: AppDb, userId: string): string[] {
return db
.select()
.from(userPermissions)
.where(eq(userPermissions.userId, userId))
.all()
return prepared(db)
.permissions.all({ userId })
.map((r) => r.permission)
}
@@ -98,6 +143,7 @@ export function createUser(
updatedAt: now,
})
.run()
bumpUserDataGeneration()
return getUserById(db, id)!
}
@@ -126,11 +172,13 @@ export function updateUser(
})
.where(eq(users.id, id))
.run()
bumpUserDataGeneration()
return getUserById(db, id)
}
export function deleteUser(db: AppDb, id: string): boolean {
const result = db.delete(users).where(eq(users.id, id)).run()
bumpUserDataGeneration()
return result.changes > 0
}
@@ -140,18 +188,55 @@ export function setUserAccess(
apps: string[],
permissions: string[],
): void {
db.delete(userApps).where(eq(userApps.userId, userId)).run()
db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run()
for (const appId of apps) {
db.insert(userApps).values({ userId, appId }).run()
}
for (const permission of permissions) {
db.insert(userPermissions).values({ userId, permission }).run()
}
db.update(users)
.set({ updatedAt: new Date().toISOString() })
.where(eq(users.id, userId))
.run()
const uniqApps = [...new Set(apps)]
const uniqPerms = [...new Set(permissions)]
withTransaction(db, () => {
db.delete(userApps).where(eq(userApps.userId, userId)).run()
db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run()
if (uniqApps.length > 0) {
db.insert(userApps)
.values(uniqApps.map((appId) => ({ userId, appId })))
.run()
}
if (uniqPerms.length > 0) {
db.insert(userPermissions)
.values(uniqPerms.map((permission) => ({ userId, permission })))
.run()
}
db.update(users)
.set({ updatedAt: new Date().toISOString() })
.where(eq(users.id, userId))
.run()
})
bumpUserDataGeneration()
}
/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */
export function searchDirectoryUsers(
db: AppDb,
query: string,
limit = 50,
): Array<{ id: string; email: string; name: string }> {
const q = query.trim().toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`)
const pattern = `%${q}%`
return db
.select({ id: users.id, email: users.email, name: users.name })
.from(users)
.where(
and(
eq(users.disabled, false),
q
? or(
sql`lower(${users.email}) LIKE ${pattern} ESCAPE '\\'`,
sql`lower(${users.name}) LIKE ${pattern} ESCAPE '\\'`,
sql`lower(${users.id}) LIKE ${pattern} ESCAPE '\\'`,
)
: undefined,
),
)
.orderBy(users.email)
.limit(limit)
.all()
}
export function touchLastLogin(
@@ -207,11 +292,9 @@ export function getUserByRefreshToken(
rawToken: string,
): UserRow | undefined {
const now = new Date().toISOString()
const session = db
.select()
.from(refreshSessions)
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
.get()
const session = prepared(db).sessionByHash.get({
hash: hashToken(rawToken),
})
if (!session || session.revokedAt) return undefined
if (session.expiresAt < now) return undefined
const user = getUserById(db, session.userId)
@@ -235,7 +318,7 @@ export function listActiveSessions(
opts: { userId?: string } = {},
): ActiveSessionRow[] {
const now = new Date().toISOString()
const rows = db
return db
.select({
id: refreshSessions.id,
userId: refreshSessions.userId,
@@ -245,30 +328,18 @@ export function listActiveSessions(
userAgent: refreshSessions.userAgent,
createdAt: refreshSessions.createdAt,
expiresAt: refreshSessions.expiresAt,
revokedAt: refreshSessions.revokedAt,
})
.from(refreshSessions)
.innerJoin(users, eq(refreshSessions.userId, users.id))
.where(
and(
isNull(refreshSessions.revokedAt),
gte(refreshSessions.expiresAt, now),
opts.userId ? eq(refreshSessions.userId, opts.userId) : undefined,
),
)
.orderBy(desc(refreshSessions.createdAt))
.all()
return rows
.filter((r) => {
if (r.revokedAt) return false
if (r.expiresAt < now) return false
if (opts.userId && r.userId !== opts.userId) return false
return true
})
.map((r) => ({
id: r.id,
userId: r.userId,
email: r.email,
name: r.name,
ip: r.ip,
userAgent: r.userAgent,
createdAt: r.createdAt,
expiresAt: r.expiresAt,
}))
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}
export function revokeSessionById(db: AppDb, sessionId: string): boolean {
+8 -3
View File
@@ -1,4 +1,4 @@
import { and, eq, lt, sql } from 'drizzle-orm'
import { and, desc, eq, lt, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
@@ -77,12 +77,17 @@ export function listWebauthnCredentials(
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId))
.orderBy(desc(webauthnCredentials.createdAt))
.all()
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}
export function countWebauthnCredentials(db: AppDb, userId: string): number {
return listWebauthnCredentials(db, userId).length
const row = db
.select({ total: sql<number>`count(*)` })
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId))
.get()
return Number(row?.total ?? 0)
}
/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */
+105
View File
@@ -0,0 +1,105 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it } from 'vitest'
import {
backupDatabase,
checkIntegrity,
closeDb,
createDb,
createUser,
getUserByEmail,
listBackups,
migrateSchema,
} from '../src/index.js'
const dirs: string[] = []
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
afterEach(() => {
for (const s of dbs.splice(0)) closeDb(s)
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
function fileDb() {
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
dirs.push(dir)
const r = createDb(join(dir, 'app.db'))
migrateSchema(r.sqlite)
dbs.push(r.sqlite)
return { dir, ...r }
}
describe('backupDatabase', () => {
it('создаёт консистентную копию с данными из WAL', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: '[email protected]', name: 'A', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
expect(existsSync(res.file)).toBe(true)
expect(checkIntegrity(res.file)).toBe(true)
const copy = new Database(res.file, { readonly: true })
const row = copy.prepare('SELECT email FROM users').get()
copy.close()
expect(row).toEqual({ email: '[email protected]' })
})
it('бэкап во время записи остаётся валидным', async () => {
const { dir, db, sqlite } = fileDb()
const writer = setInterval(() => {
try {
createUser(db, {
email: `u${Math.random()}@x.io`,
name: 'U',
passwordHash: 'h',
isAdmin: false,
})
} catch {
/* БД могла закрыться */
}
}, 1)
const res = await backupDatabase(sqlite, { dir, keep: 3 })
clearInterval(writer)
expect(checkIntegrity(res.file)).toBe(true)
})
it('ротация оставляет keep последних', async () => {
const { dir, sqlite } = fileDb()
const bdir = join(dir, 'b')
for (let i = 0; i < 5; i++) {
await backupDatabase(sqlite, {
dir: bdir,
keep: 2,
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
})
}
const left = listBackups(bdir)
expect(left).toHaveLength(2)
expect(left[0]).toContain('000004')
})
it('восстановленная копия открывается и содержит данные', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: '[email protected]', name: 'R', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir, keep: 1 })
const restored = createDb(res.file)
dbs.push(restored.sqlite)
migrateSchema(restored.sqlite)
expect(getUserByEmail(restored.db, '[email protected]')?.name).toBe('R')
})
it('битый файл не проходит integrity_check', () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
dirs.push(dir)
const p = join(dir, 'bad.db')
writeFileSync(p, 'not a sqlite file'.repeat(100))
expect(() => checkIntegrity(p)).toThrow()
})
it('ошибка бэкапа не оставляет .partial', async () => {
const { dir, sqlite } = fileDb()
closeDb(sqlite)
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
})
})
+426
View File
@@ -0,0 +1,426 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it } from 'vitest'
import {
SCHEMA_VERSION,
appendAudit,
appendAuditBatch,
closeDb,
consumeOidcAuthCode,
countWebauthnCredentials,
createDb,
createOidcAuthCode,
createRefreshSession,
createUser,
createWebauthnCredential,
getActiveOidcSigningKey,
getUserApps,
getUserById,
getUserPermissions,
insertOidcSigningKey,
listActiveSessions,
listAudit,
migrateSchema,
revokeSessionById,
runHousekeeping,
searchDirectoryUsers,
setUserAccess,
withTransaction,
type AppDb,
type Sqlite,
} from '../src/index.js'
const open: Sqlite[] = []
const dirs: string[] = []
function memDb(): { db: AppDb; sqlite: Sqlite } {
const r = createDb('sqlite::memory:')
migrateSchema(r.sqlite)
open.push(r.sqlite)
return r
}
afterEach(() => {
for (const s of open.splice(0)) closeDb(s)
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
function mkUser(db: AppDb, email: string, extra: { name?: string } = {}) {
return createUser(db, {
email,
name: extra.name ?? email.split('@')[0]!,
passwordHash: 'x',
isAdmin: false,
})
}
describe('createDb / closeDb', () => {
it('выставляет pragma на файловой БД', () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-db-'))
dirs.push(dir)
const { sqlite } = createDb(join(dir, 'app.db'))
open.push(sqlite)
expect(sqlite.pragma('journal_mode', { simple: true })).toBe('wal')
expect(sqlite.pragma('busy_timeout', { simple: true })).toBe(5000)
expect(sqlite.pragma('synchronous', { simple: true })).toBe(1)
expect(sqlite.pragma('temp_store', { simple: true })).toBe(2)
expect(sqlite.pragma('cache_size', { simple: true })).toBe(-16000)
expect(sqlite.pragma('foreign_keys', { simple: true })).toBe(1)
})
it('closeDb идемпотентен', () => {
const { sqlite } = memDb()
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
})
})
describe('migrateSchema', () => {
it('ставит user_version и повторный запуск — no-op', () => {
const { sqlite } = memDb()
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
expect(() => migrateSchema(sqlite)).not.toThrow()
})
it('создаёт новые индексы', () => {
const { sqlite } = memDb()
const names = (
sqlite
.prepare(`SELECT name FROM sqlite_master WHERE type='index'`)
.all() as Array<{ name: string }>
).map((r) => r.name)
for (const n of [
'uq_user_apps',
'uq_user_permissions',
'idx_refresh_sessions_expires',
'idx_oidc_auth_codes_expires',
'idx_audit_log_severity',
'idx_audit_log_actor_email',
]) {
expect(names).toContain(n)
}
})
it('мигрирует «старую» схему: дедупликация и недостающие колонки', () => {
const sqlite = new Database(':memory:')
open.push(sqlite)
sqlite.pragma('foreign_keys = ON')
sqlite.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL, password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE user_apps (user_id TEXT NOT NULL, app_id TEXT NOT NULL);
CREATE TABLE user_permissions (user_id TEXT NOT NULL, permission TEXT NOT NULL);
INSERT INTO users VALUES ('u1','[email protected]','A','h',0,0,'t','t');
INSERT INTO user_apps VALUES ('u1','dns'),('u1','dns'),('u1','vps');
INSERT INTO user_permissions VALUES ('u1','p'),('u1','p');
`)
migrateSchema(sqlite)
expect(
sqlite.prepare('SELECT COUNT(*) c FROM user_apps').get(),
).toEqual({ c: 2 })
expect(
sqlite.prepare('SELECT COUNT(*) c FROM user_permissions').get(),
).toEqual({ c: 1 })
const cols = (
sqlite.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>
).map((c) => c.name)
expect(cols).toContain('last_login_at')
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
expect(() =>
sqlite.prepare(`INSERT INTO user_apps VALUES ('u1','dns')`).run(),
).toThrow(/UNIQUE/)
})
it('откатывается целиком при ошибке', () => {
const sqlite = new Database(':memory:')
open.push(sqlite)
// users — это view-подобная помеха: таблица с несовместимой схемой user_apps без user_id
sqlite.exec(`CREATE TABLE user_apps (foo TEXT)`)
expect(() => migrateSchema(sqlite)).toThrow()
expect(sqlite.pragma('user_version', { simple: true })).toBe(0)
})
})
describe('withTransaction / setUserAccess', () => {
it('откатывает все изменения при исключении', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
setUserAccess(db, u.id, ['dns'], ['p1'])
expect(() =>
withTransaction(db, () => {
setUserAccess(db, u.id, ['vps', 'bgp'], ['p2'])
throw new Error('boom')
}),
).toThrow('boom')
expect(getUserApps(db, u.id)).toEqual(['dns'])
expect(getUserPermissions(db, u.id)).toEqual(['p1'])
})
it('схлопывает дубликаты во входе', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
setUserAccess(db, u.id, ['dns', 'dns'], ['p', 'p'])
expect(getUserApps(db, u.id)).toEqual(['dns'])
expect(getUserPermissions(db, u.id)).toEqual(['p'])
})
it('вложенная транзакция откатывается как savepoint', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
withTransaction(db, () => {
setUserAccess(db, u.id, ['dns'], [])
try {
withTransaction(db, () => {
setUserAccess(db, u.id, ['vps'], [])
throw new Error('inner')
})
} catch {
/* ожидаемо */
}
})
expect(getUserApps(db, u.id)).toEqual(['dns'])
})
})
describe('сессии и каталог', () => {
it('listActiveSessions: только активные, фильтр по пользователю, сортировка', () => {
const { db } = memDb()
const a = mkUser(db, '[email protected]')
const b = mkUser(db, '[email protected]')
const future = new Date(Date.now() + 3600_000)
const past = new Date(Date.now() - 3600_000)
const s1 = createRefreshSession(db, a.id, 't1', future)
createRefreshSession(db, a.id, 't2', past)
const s3 = createRefreshSession(db, b.id, 't3', future)
const s4 = createRefreshSession(db, b.id, 't4', future)
revokeSessionById(db, s4)
const all = listActiveSessions(db).map((s) => s.id)
expect(new Set(all)).toEqual(new Set([s1, s3]))
expect(listActiveSessions(db, { userId: a.id }).map((s) => s.id)).toEqual([
s1,
])
})
it('searchDirectoryUsers: без passwordHash, без disabled, LIMIT, экранирование', () => {
const { db } = memDb()
for (let i = 0; i < 60; i++) mkUser(db, `user${i}@x.io`)
const weird = mkUser(db, '[email protected]', { name: '100%' })
const dis = mkUser(db, '[email protected]')
db.$client.prepare('UPDATE users SET disabled=1 WHERE id=?').run(dis.id)
const page = searchDirectoryUsers(db, '', 50)
expect(page).toHaveLength(50)
expect(Object.keys(page[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(searchDirectoryUsers(db, '[email protected]')).toEqual([])
expect(searchDirectoryUsers(db, 'USER7@').map((u) => u.email)).toEqual([
'[email protected]',
])
expect(searchDirectoryUsers(db, '%').map((u) => u.id)).toEqual([weird.id])
expect(searchDirectoryUsers(db, weird.id)).toHaveLength(1)
})
it('countWebauthnCredentials считает COUNT(*)', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
expect(countWebauthnCredentials(db, u.id)).toBe(0)
for (const id of ['c1', 'c2']) {
createWebauthnCredential(db, {
userId: u.id,
credentialId: id,
publicKey: 'k',
counter: 0,
name: id,
})
}
expect(countWebauthnCredentials(db, u.id)).toBe(2)
})
})
describe('OIDC', () => {
function code(db: AppDb, userId: string, raw: string, expiresAt: Date) {
createOidcAuthCode(db, {
rawCode: raw,
clientId: 'c',
userId,
redirectUri: 'https://x/cb',
scope: 'openid',
expiresAt,
})
}
it('consumeOidcAuthCode: одноразовый, просроченный отклоняется', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
code(db, u.id, 'ok', new Date(Date.now() + 60_000))
code(db, u.id, 'old', new Date(Date.now() - 60_000))
expect(consumeOidcAuthCode(db, 'ok')?.userId).toBe(u.id)
expect(consumeOidcAuthCode(db, 'ok')).toBeUndefined()
expect(consumeOidcAuthCode(db, 'old')).toBeUndefined()
expect(consumeOidcAuthCode(db, 'missing')).toBeUndefined()
})
it('insertOidcSigningKey оставляет один активный ключ — новый', () => {
const { db } = memDb()
insertOidcSigningKey(db, { kid: 'k1', privatePem: 'p', publicJwkJson: '{}' })
insertOidcSigningKey(db, { kid: 'k2', privatePem: 'p', publicJwkJson: '{}' })
expect(getActiveOidcSigningKey(db)?.kid).toBe('k2')
const active = db.$client
.prepare('SELECT COUNT(*) c FROM oidc_signing_keys WHERE active=1')
.get()
expect(active).toEqual({ c: 1 })
})
})
describe('audit', () => {
it('appendAudit: дубликат event_id → false', () => {
const { db } = memDb()
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
true,
)
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
false,
)
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
})
it('appendAuditBatch: принимает, пропускает дубли, не теряет остальные', () => {
const { db } = memDb()
const events = Array.from({ length: 500 }, (_, i) => ({
eventId: `e${i % 400}`,
action: 'x.y',
summary: 's',
}))
const res = appendAuditBatch(db, events)
expect(res).toEqual({ accepted: 400, duplicates: 100, failed: 0 })
expect(listAudit(db, { limit: 1000 })).toHaveLength(400)
})
it('listAudit kind=logins/changes делит по префиксу auth.', () => {
const { db } = memDb()
appendAudit(db, { action: 'auth.login', summary: 's' })
appendAudit(db, { action: 'auth.login_failed', summary: 's' })
appendAudit(db, { action: 'user.create', summary: 's' })
appendAudit(db, { action: 'audit.purge', summary: 's' })
appendAudit(db, { action: 'authz.other', summary: 's' })
const logins = listAudit(db, { kind: 'logins' }).map((e) => e.action)
const changes = listAudit(db, { kind: 'changes' }).map((e) => e.action)
expect(logins.sort()).toEqual(['auth.login', 'auth.login_failed'])
expect(changes.sort()).toEqual(['audit.purge', 'authz.other', 'user.create'])
})
})
describe('runHousekeeping', () => {
it('удаляет старые истёкшие/отозванные, оставляя активные и свежие', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
const day = 86_400_000
const active = createRefreshSession(db, u.id, 'a', new Date(Date.now() + day))
createRefreshSession(db, u.id, 'oldexp', new Date(Date.now() - 10 * day))
createRefreshSession(db, u.id, 'freshexp', new Date(Date.now() - day))
const oldRevoked = createRefreshSession(
db,
u.id,
'oldrev',
new Date(Date.now() + day),
)
db.$client
.prepare('UPDATE refresh_sessions SET revoked_at=? WHERE id=?')
.run(new Date(Date.now() - 10 * day).toISOString(), oldRevoked)
createOidcAuthCode(db, {
rawCode: 'stale',
clientId: 'c',
userId: u.id,
redirectUri: 'r',
scope: 's',
expiresAt: new Date(Date.now() - 10 * day),
})
createOidcAuthCode(db, {
rawCode: 'live',
clientId: 'c',
userId: u.id,
redirectUri: 'r',
scope: 's',
expiresAt: new Date(Date.now() + 60_000),
})
db.$client
.prepare(
`INSERT INTO webauthn_challenges (id,purpose,challenge,expires_at,created_at)
VALUES ('c1','register','x',?,?)`,
)
.run(new Date(Date.now() - 1000).toISOString(), new Date().toISOString())
const res = runHousekeeping(db, { retentionDays: 7 })
expect(res).toEqual({ sessions: 2, authCodes: 1, challenges: 1 })
const left = db.$client
.prepare('SELECT id FROM refresh_sessions')
.all() as Array<{ id: string }>
expect(left).toHaveLength(2)
expect(left.map((r) => r.id)).toContain(active)
expect(runHousekeeping(db, { retentionDays: 7 })).toEqual({
sessions: 0,
authCodes: 0,
challenges: 0,
})
})
it('удаляет пачками > 1000 строк', () => {
const { db } = memDb()
const u = mkUser(db, '[email protected]')
const ins = db.$client.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES (?,?,?,?,?)`,
)
db.$client.transaction(() => {
for (let i = 0; i < 2500; i++) {
ins.run(`s${i}`, u.id, `h${i}`, '2000-01-01T00:00:00.000Z', 't')
}
})()
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()
})
})
+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)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 50 },
},
},
})
+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 },
})
+6 -4
View File
@@ -18,6 +18,7 @@
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
@@ -25,12 +26,13 @@
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"@eslint/js": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
APP_IDS,
allPermissionKeys,
appsMetaFromSwitcher,
buildSsoRedirectUrl,
createOidcClientRequestSchema,
defaultAppSwitcherConfig,
hasPermission,
isJwtExpired,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
loginRequestSchema,
normalizeAppSwitcherConfig,
normalizePermissionKeys,
oidcGroupsForUser,
parseAppSwitcherConfig,
permissionKey,
publicAppSwitcherConfig,
readJwtExp,
readJwtPayload,
tenantsClaimForUser,
} from '../src/index.js'
function jwt(payload: object): string {
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('permissions', () => {
it('иерархия admin ⊃ write ⊃ read в пределах секции', () => {
expect(hasPermission(['dns:dns:admin'], 'dns:dns:write')).toBe(true)
expect(hasPermission(['dns:dns:admin'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:write'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:read'], 'dns:dns:write')).toBe(false)
expect(hasPermission(['dns:dns:admin'], 'dns:dhcp:read')).toBe(false)
expect(hasPermission([], 'broken')).toBe(false)
})
it('permissionKey / allPermissionKeys согласованы', () => {
expect(permissionKey('dns', 'dns', 'read')).toBe('dns:dns:read')
const all = allPermissionKeys()
expect(new Set(all).size).toBe(all.length)
expect(all).toContain('dns:dns:read')
})
it('normalizePermissionKeys: легаси мапятся, неизвестные отбрасываются, результат отсортирован', () => {
const out = normalizePermissionKeys([
'bgp:peers:read',
'bgp:settings:admin',
'nope:x:read',
'dns:dns:read',
'dns:dns:read',
])
expect(out).toEqual([...out].sort())
expect(out).toContain('bgp:network:read')
expect(out).toContain('bgp:tenant_settings:admin')
expect(out).toContain('dns:dns:read')
expect(out).not.toContain('nope:x:read')
expect(out.filter((k) => k === 'dns:dns:read')).toHaveLength(1)
})
})
describe('oidcGroupsForUser', () => {
it('админ получает все группы Technitium', () => {
expect(oidcGroupsForUser({ isAdmin: true, permissions: [] })).toEqual([
'technitium_admins',
'technitium_dns_admins',
'technitium_dhcp_admins',
])
})
it('права dns/dhcp дают соответствующие группы', () => {
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dns:write'] }),
).toEqual(['technitium_dns_admins'])
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dhcp:admin'] }),
).toEqual(['technitium_dhcp_admins'])
expect(oidcGroupsForUser({ isAdmin: false, permissions: [] })).toEqual([])
})
})
describe('isReturnToAllowed', () => {
it.each([
['https://cfdm.shnt.top/x', '.shnt.top', true],
['https://shnt.top', '.shnt.top', true],
['https://evilshnt.top', '.shnt.top', false],
['http://localhost:5173/cb', 'localhost', true],
['http://192.168.1.5/x', 'private', true],
['http://172.32.0.1/x', 'private', false],
['https://8.8.8.8', 'private', false],
['http://localhost:5173/a', 'http://localhost:5173', true],
['http://localhost:5174/a', 'http://localhost:5173', false],
['javascript:alert(1)', 'localhost', false],
['not a url', 'localhost', false],
])('%s vs %s → %s', (url, list, expected) => {
expect(isReturnToAllowed(url, list)).toBe(expected)
})
})
describe('JWT helpers', () => {
it('читает payload и exp, определяет протухание с запасом 30 c', () => {
const exp = Math.floor(Date.now() / 1000) + 3600
const t = jwt({ sub: 'u1', exp })
expect(readJwtPayload(t)).toMatchObject({ sub: 'u1' })
expect(readJwtExp(t)).toBe(exp)
expect(isJwtExpired(t)).toBe(false)
expect(isJwtExpired(jwt({ exp: 1 }))).toBe(true)
const justExpired = jwt({ exp: Math.floor(Date.now() / 1000) - 10 })
expect(isJwtExpired(justExpired)).toBe(false)
expect(isJwtExpired(jwt({ sub: 'no-exp' }))).toBe(true)
expect(readJwtPayload('garbage')).toBeNull()
expect(readJwtExp('a.b')).toBeNull()
})
it('buildSsoRedirectUrl кладёт токен во фрагмент и заменяет старый', () => {
const url = buildSsoRedirectUrl('https://a.shnt.top/p?x=1#old', 'T', 'E')
expect(url.startsWith('https://a.shnt.top/p?x=1#')).toBe(true)
expect(url).toContain('access_token=T')
expect(url).not.toContain('old')
})
})
describe('app switcher', () => {
it('default содержит все приложения', () => {
const d = defaultAppSwitcherConfig()
expect(d.apps.map((a) => a.id).sort()).toEqual([...APP_IDS].sort())
})
it('parseAppSwitcherConfig: мусор → default, неполный конфиг дополняется', () => {
expect(parseAppSwitcherConfig({ nope: 1 })).toEqual(defaultAppSwitcherConfig())
const partial = parseAppSwitcherConfig({
menuLabel: 'Меню',
apps: [
{
id: 'dns',
name: 'DNS!',
url: 'https://dns.example.com',
icon: 'globe',
enabled: false,
tenantId: ' ',
},
],
})
expect(partial.menuLabel).toBe('Меню')
expect(partial.apps).toHaveLength(APP_IDS.length)
const dns = partial.apps.find((a) => a.id === 'dns')!
expect(dns.name).toBe('DNS!')
expect(dns.enabled).toBe(false)
expect(dns.tenantId).toBeUndefined()
})
it('public-конфиг без tenantId; tenants-claim только по доступным приложениям', () => {
const base = defaultAppSwitcherConfig()
const cfg = normalizeAppSwitcherConfig({
...base,
apps: base.apps.map((a) => (a.id === 'bgp' ? { ...a, tenantId: 'tenant-1' } : a)),
})
expect(JSON.stringify(publicAppSwitcherConfig(cfg))).not.toContain('tenant-1')
expect(tenantsClaimForUser(cfg, ['bgp'])).toEqual({ bgp: 'tenant-1' })
expect(tenantsClaimForUser(cfg, ['dns'])).toEqual({})
})
it('appsMetaFromSwitcher скрывает отключённые приложения', () => {
const cfg = defaultAppSwitcherConfig()
cfg.apps = cfg.apps.map((a) => (a.id === 'dns' ? { ...a, enabled: false } : a))
expect(appsMetaFromSwitcher(cfg).some((a) => a.id === 'dns')).toBe(false)
})
})
describe('zod-схемы', () => {
it('login: email и непустой пароль обязательны', () => {
expect(loginRequestSchema.safeParse({ email: '[email protected]', password: 'x' }).success).toBe(true)
expect(loginRequestSchema.safeParse({ email: 'bad', password: 'x' }).success).toBe(false)
expect(loginRequestSchema.safeParse({ email: '[email protected]', password: '' }).success).toBe(false)
})
it('createOidcClient: scopes по умолчанию, redirect_uris не пустой', () => {
const ok = createOidcClientRequestSchema.parse({
name: 'c',
redirect_uris: ['https://x.example/cb'],
})
expect(ok.scopes).toContain('openid')
expect(ok.enabled).toBe(true)
expect(
createOidcClientRequestSchema.safeParse({ name: 'c', redirect_uris: [] }).success,
).toBe(false)
})
it('isPortalOidcAuthorizeUrl', () => {
const iss = 'https://auth.shnt.top'
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize?x=1`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize/`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl('https://evil.example/oauth/authorize', iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl(`${iss}/other`, iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl('nope', iss)).toBe(false)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 90 },
},
},
})
+1 -1
View File
@@ -14,7 +14,7 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@base-ui/react": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "catalog:",
+75 -42
View File
@@ -8,37 +8,37 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264.0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.141 0.006 264.0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--popover-foreground: oklch(0.141 0.006 264.0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.520 0.010 264.0);
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264.0);
--input: oklch(0.904 0.006 264.0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.546 0.215 264.376);
--chart-2: oklch(0.527 0.154 150.069);
--chart-3: oklch(0.625 0.162 41.500);
--chart-4: oklch(0.591 0.224 296.400);
--chart-5: oklch(0.577 0.245 27.325);
--sidebar: oklch(0.152 0.010 264.376);
--sidebar-foreground: oklch(0.920 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--sidebar-accent: oklch(0.240 0.012 264.376);
--sidebar-accent-foreground: oklch(0.950 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-800);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-900);
@@ -50,40 +50,55 @@
--invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-900);
--chart-line-1: oklch(0.527 0.154 150.069);
--chart-line-2: oklch(0.546 0.215 264.376);
--chart-line-3: oklch(0.625 0.162 41.500);
--chart-line-4: oklch(0.591 0.224 296.400);
--chart-rx: oklch(0.527 0.154 150.069);
--chart-tx: oklch(0.546 0.215 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.437 0.149 150.069);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.10);
--status-offline-fg: oklch(0.505 0.213 27.325);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.10);
--status-degraded-fg: oklch(0.555 0.163 58.318);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.10);
}
.dark {
--background: oklch(0.145 0 0);
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.252 0.010 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--muted: oklch(0.252 0.010 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.637 0.222 264.376);
--chart-2: oklch(0.696 0.170 151.328);
--chart-3: oklch(0.713 0.176 47.600);
--chart-4: oklch(0.714 0.173 303.900);
--chart-5: oklch(0.704 0.191 22.216);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--sidebar-ring: oklch(0.488 0.243 264.376);
--destructive-foreground: var(--color-red-600);
--success: var(--color-emerald-500);
--success-foreground: var(--color-emerald-600);
@@ -95,6 +110,21 @@
--invert-foreground: var(--color-zinc-50);
--focus: var(--color-blue-500);
--focus-foreground: var(--color-blue-600);
--chart-line-1: oklch(0.696 0.170 151.328);
--chart-line-2: oklch(0.637 0.222 264.376);
--chart-line-3: oklch(0.713 0.176 47.600);
--chart-line-4: oklch(0.714 0.173 303.900);
--chart-rx: oklch(0.696 0.170 151.328);
--chart-tx: oklch(0.637 0.222 264.376);
--status-online: oklch(0.527 0.154 150.069);
--status-offline: oklch(0.577 0.245 27.325);
--status-degraded: oklch(0.666 0.179 58.318);
--status-online-fg: oklch(0.696 0.170 151.328);
--status-online-bg: oklch(0.527 0.154 150.069 / 0.12);
--status-offline-fg: oklch(0.704 0.191 22.216);
--status-offline-bg: oklch(0.577 0.245 27.325 / 0.12);
--status-degraded-fg: oklch(0.769 0.149 70.080);
--status-degraded-bg: oklch(0.666 0.179 58.318 / 0.12);
}
@theme inline {
@@ -129,10 +159,13 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-invert-foreground: var(--invert-foreground);
--color-invert: var(--invert);
--color-warning-foreground: var(--warning-foreground);
+785 -116
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -10,3 +10,9 @@ catalog:
vitest: "^4.1.8"
zod: "^4.4.3"
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"
"@types/node": "^22.15.32"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose),
# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных.
#
# scripts/ci/smoke-image.sh <image>
#
# Запросы идут ВНУТРИ контейнера (docker exec + busybox wget): раннер CI сам работает
# в контейнере, и опубликованный порт хоста с 127.0.0.1 задания недостижим.
#
# Бюджеты (env): MAX_IMAGE_MB (250), MAX_RSS_MB (200), MAX_START_S (15), MAX_STOP_S (5)
set -euo pipefail
IMAGE="${1:?usage: smoke-image.sh <image>}"
MAX_IMAGE_MB="${MAX_IMAGE_MB:-250}"
MAX_RSS_MB="${MAX_RSS_MB:-200}"
MAX_START_S="${MAX_START_S:-15}"
MAX_STOP_S="${MAX_STOP_S:-5}"
NAME="authportal-smoke-$$"
VOLUME="authportal-smoke-data-$$"
SECRET="smoke-jwt-secret-0123456789"
ADMIN_PW="smoke-admin-pass-1"
BASE="http://127.0.0.1:8080"
fail() { echo "SMOKE FAIL: $*" >&2; docker logs --tail 50 "$NAME" >&2 || true; exit 1; }
cleanup() {
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker volume rm -f "$VOLUME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# GET (или POST с JSON-телом) изнутри контейнера; тело ответа в stdout.
req() {
local url="$1" body="${2:-}"
if [ -n "$body" ]; then
docker exec "$NAME" wget -q -O - -T 5 \
--header='content-type: application/json' --post-data="$body" "$url"
else
docker exec "$NAME" wget -q -O - -T 5 "$url"
fi
}
run_container() {
docker run -d --name "$NAME" --init \
--memory 256m --memory-swap 256m --cpus 1.0 --pids-limit 128 \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \
-v "$VOLUME:/data" \
-e JWT_SECRET="$SECRET" -e ADMIN_PASSWORD="$ADMIN_PW" \
-e ADMIN_EMAIL=[email protected] -e ISSUER="$BASE" \
"$IMAGE" >/dev/null
}
wait_ready() {
local start deadline
start=$(date +%s)
deadline=$((start + MAX_START_S))
until req "$BASE/ready" >/dev/null 2>&1; do
[ "$(date +%s)" -lt "$deadline" ] || fail "/ready не ответил за ${MAX_START_S}s"
sleep 0.3
done
echo "ready за $(( $(date +%s) - start ))s"
}
login() {
req "$BASE/api/v1/auth/login" \
"{\"email\":\"[email protected]\",\"password\":\"${ADMIN_PW}\"}"
}
jwks_kid() {
req "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/'
}
# 1. размер образа
size_mb=$(( $(docker image inspect "$IMAGE" --format '{{.Size}}') / 1024 / 1024 ))
echo "image size: ${size_mb} MiB (budget ${MAX_IMAGE_MB})"
[ "$size_mb" -le "$MAX_IMAGE_MB" ] || fail "образ ${size_mb} MiB > ${MAX_IMAGE_MB} MiB"
# 2. старт, ready, логин, JWKS
docker volume create "$VOLUME" >/dev/null
docker run --rm -v "$VOLUME:/data" --user root --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true
run_container
wait_ready
[ "$(req "$BASE/health")" = '{"ok":true}' ] || fail "/health"
login | grep -q '"access_token"' || fail "логин администратора"
kid1=$(jwks_kid)
[ -n "$kid1" ] || fail "JWKS пуст"
# wget на 404 завершается с ошибкой (pipefail!) — собираем вывод отдельно.
not_found=$(docker exec "$NAME" wget -S -O /dev/null -T 5 "$BASE/oauth/nope" 2>&1 || true)
echo "$not_found" | grep -q "HTTP/1.[01] 404" || fail "/oauth/nope: ожидался 404, получено: $not_found"
# 3. память под нагрузкой логинов
for _ in $(seq 1 10); do login >/dev/null; done
rss_mb=$(docker stats --no-stream --format '{{.MemUsage}}' "$NAME" \
| sed -E 's/^([0-9.]+)([KMG]i?B).*/\1 \2/' \
| awk '{u=$2; v=$1; if(u ~ /^G/) v*=1024; else if(u ~ /^K/) v/=1024; printf "%d", v}')
echo "RSS: ${rss_mb} MiB (budget ${MAX_RSS_MB})"
[ "$rss_mb" -le "$MAX_RSS_MB" ] || fail "RSS ${rss_mb} MiB > ${MAX_RSS_MB} MiB"
# 4. быстрый graceful stop
t0=$(date +%s.%N)
docker stop -t 15 "$NAME" >/dev/null
elapsed=$(awk -v a="$t0" -v b="$(date +%s.%N)" 'BEGIN{printf "%.1f", b-a}')
echo "docker stop: ${elapsed}s (budget ${MAX_STOP_S}s)"
awk -v e="$elapsed" -v m="$MAX_STOP_S" 'BEGIN{exit !(e<=m)}' || fail "docker stop ${elapsed}s > ${MAX_STOP_S}s"
[ "$(docker inspect "$NAME" --format '{{.State.ExitCode}}')" = 0 ] || fail "код выхода после SIGTERM не 0"
# 5. данные переживают рестарт (тот же ключ подписи и тот же админ)
docker rm "$NAME" >/dev/null
run_container
wait_ready
login | grep -q '"access_token"' || fail "логин после рестарта"
kid2=$(jwks_kid)
[ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)"
echo "SMOKE OK"
+48
View File
@@ -0,0 +1,48 @@
// Бюджет размера web-бандла: node scripts/ci/web-budget.mjs [dist/assets]
// Лимиты — gzip-байты; переопределяются env WEB_BUDGET_ENTRY_GZ / WEB_BUDGET_TOTAL_GZ.
import { readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { gzipSync } from 'node:zlib'
export const DEFAULT_LIMITS = {
entryGz: 25 * 1024,
totalGz: 560 * 1024,
}
export function measure(dir) {
let total = 0
let entry = 0
for (const name of readdirSync(dir)) {
if (!name.endsWith('.js')) continue
const gz = gzipSync(readFileSync(join(dir, name))).length
total += gz
if (/^index-.*\.js$/.test(name)) entry = Math.max(entry, gz)
}
return { entryGz: entry, totalGz: total }
}
export function checkBudget(dir, limits = DEFAULT_LIMITS) {
const sizes = measure(dir)
const errors = []
if (sizes.entryGz === 0) errors.push('entry chunk index-*.js не найден')
if (sizes.entryGz > limits.entryGz)
errors.push(`entry gzip ${sizes.entryGz} > ${limits.entryGz}`)
if (sizes.totalGz > limits.totalGz)
errors.push(`total gzip ${sizes.totalGz} > ${limits.totalGz}`)
return { sizes, errors }
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const dir = process.argv[2] ?? 'apps/web/dist/assets'
const limits = {
entryGz: Number(process.env.WEB_BUDGET_ENTRY_GZ ?? DEFAULT_LIMITS.entryGz),
totalGz: Number(process.env.WEB_BUDGET_TOTAL_GZ ?? DEFAULT_LIMITS.totalGz),
}
const { sizes, errors } = checkBudget(dir, limits)
console.log('web bundle (gzip):', sizes, 'limits:', limits)
if (errors.length) {
console.error('BUDGET EXCEEDED:\n - ' + errors.join('\n - '))
process.exit(1)
}
}
+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 ?? '[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')
+9 -1
View File
@@ -13,7 +13,15 @@
"dependsOn": ["^build"],
"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": {
"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/**"]
},
"lint": {}
}
}