Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
395e37ea7a | ||
|
|
1a68597d48 | ||
|
|
08be310e6b | ||
|
|
5cbf4d29d7 | ||
|
|
22dd6f9937 | ||
|
|
8cba7a5c41 | ||
|
|
8298454c63 | ||
|
|
1d7ea35a23 | ||
|
|
30d331c633 | ||
|
|
107eddba9e | ||
|
|
140ffdb7b1 |
No files matched your search
@@ -42,6 +42,7 @@ package-lock.json
|
||||
data
|
||||
*.exe
|
||||
**/*.test.ts
|
||||
**/test/**
|
||||
**/*.spec.ts
|
||||
coverage
|
||||
.coverage
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: setup-pnpm
|
||||
description: Node 22 + кэш pnpm store / corepack / node_modules / .turbo по хешу lockfile
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Был ли точный хит кэша (передайте в PNPM_CACHE_HIT для scripts/ci/pnpm-ci.sh)
|
||||
value: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
shell: sh
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
shell: bash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
.turbo
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
@@ -129,6 +129,8 @@ jobs:
|
||||
env:
|
||||
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 }}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Ночной нагрузочный smoke: образ под лимитами compose + autocannon + бюджеты p99/RPS/RSS.
|
||||
name: nightly-perf
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
load:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- id: setup
|
||||
uses: ./.gitea/actions/setup-pnpm
|
||||
- name: Build image
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --set 'authportal.output=type=docker' --set 'authportal.tags=authportal:load' authportal
|
||||
- name: Run container with production limits
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
docker volume create load-data
|
||||
docker run --rm -v load-data:/data --entrypoint sh authportal:load -c 'chown 1000:1000 /data'
|
||||
docker run -d --name authportal-load --init --memory 256m --memory-swap 256m --cpus 1.0 \
|
||||
--read-only --tmpfs /tmp --cap-drop ALL -v load-data:/data -p 18080:8080 \
|
||||
-e JWT_SECRET=load-jwt-secret-0123456789 -e ADMIN_PASSWORD=load-admin-pass-1 \
|
||||
-e [email protected] -e ISSUER=http://127.0.0.1:18080 -e RATE_LIMIT_MAX=1000000 authportal:load
|
||||
for i in $(seq 1 40); do curl -fsS http://127.0.0.1:18080/ready && break; sleep 0.5; done
|
||||
- name: Load test
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.setup.outputs.cache-hit }}
|
||||
LOAD_PASSWORD: load-admin-pass-1
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
node scripts/perf/load.mjs http://127.0.0.1:18080 authportal-load
|
||||
- name: Container logs on failure
|
||||
if: failure()
|
||||
run: docker logs --tail 100 authportal-load || true
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -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
|
||||
@@ -36,3 +36,5 @@ version.txt
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
.claude/settings.local.json
|
||||
|
||||
coverage
|
||||
+11
-9
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
/* результат не важен */
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
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('нативные модули проверяются при сборке', () => {
|
||||
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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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:')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
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'),
|
||||
},
|
||||
clean: true,
|
||||
})
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,12 @@ export function ExplainBanner({
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
void navigate({ to: '/design-preview/logins' })
|
||||
void navigate({
|
||||
// В прод-образе preview-маршруты вырезаны (см. Dockerfile) — ведём в реальный журнал.
|
||||
to: window.location.pathname.startsWith('/design-preview')
|
||||
? '/design-preview/logins'
|
||||
: '/admin/logins',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Открыть журнал входов
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearToken,
|
||||
getToken,
|
||||
jwtClaimsStaleVsMe,
|
||||
setToken,
|
||||
} from '@/lib/auth'
|
||||
import { ApiError, api } from '@/lib/api-client'
|
||||
|
||||
function jwt(payload: object): string {
|
||||
const b64 = (o: object) => btoa(JSON.stringify(o)).replace(/=+$/, '')
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
|
||||
}
|
||||
const future = () => Math.floor(Date.now() / 1000) + 3600
|
||||
|
||||
beforeEach(() => localStorage.clear())
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('хранилище токена', () => {
|
||||
it('отдаёт валидный токен, удаляет просроченный', () => {
|
||||
const good = jwt({ exp: future() })
|
||||
setToken(good)
|
||||
expect(getToken()).toBe(good)
|
||||
|
||||
setToken(jwt({ exp: 1 }))
|
||||
expect(getToken()).toBeNull()
|
||||
expect(localStorage.getItem('authportal_token')).toBeNull()
|
||||
})
|
||||
|
||||
it('clearToken очищает', () => {
|
||||
setToken(jwt({ exp: future() }))
|
||||
clearToken()
|
||||
expect(getToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('jwtClaimsStaleVsMe', () => {
|
||||
const me = {
|
||||
id: 'u1',
|
||||
email: '[email protected]',
|
||||
name: 'A',
|
||||
is_admin: false,
|
||||
apps: ['dns', 'vps'],
|
||||
permissions: ['dns:dns:read'],
|
||||
} as never
|
||||
|
||||
it('совпадающие клейм-ы не устарели (порядок не важен)', () => {
|
||||
const t = jwt({ is_admin: false, apps: ['vps', 'dns'], permissions: ['dns:dns:read'] })
|
||||
expect(jwtClaimsStaleVsMe(t, me)).toBe(false)
|
||||
})
|
||||
|
||||
it('любое расхождение — устарел', () => {
|
||||
expect(jwtClaimsStaleVsMe(jwt({ is_admin: true, apps: ['dns', 'vps'], permissions: ['dns:dns:read'] }), me)).toBe(true)
|
||||
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns'], permissions: ['dns:dns:read'] }), me)).toBe(true)
|
||||
expect(jwtClaimsStaleVsMe(jwt({ is_admin: false, apps: ['dns', 'vps'], permissions: [] }), me)).toBe(true)
|
||||
expect(jwtClaimsStaleVsMe('garbage', me)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('api-client', () => {
|
||||
function mockFetch(status: number, body?: string) {
|
||||
return vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(body ?? null, { status }),
|
||||
)
|
||||
}
|
||||
|
||||
it('добавляет Bearer и разбирает JSON', async () => {
|
||||
const token = jwt({ exp: future() })
|
||||
setToken(token)
|
||||
const spy = mockFetch(200, JSON.stringify({ ok: 1 }))
|
||||
await expect(api.get('/api/v1/x')).resolves.toEqual({ ok: 1 })
|
||||
const init = spy.mock.calls[0]![1] as RequestInit
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${token}`)
|
||||
expect(init.credentials).toBe('include')
|
||||
})
|
||||
|
||||
it('204 → undefined', async () => {
|
||||
mockFetch(204)
|
||||
await expect(api.get('/api/v1/x')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('401 очищает токен и бросает ApiError с кодом сервера', async () => {
|
||||
setToken(jwt({ exp: future() }))
|
||||
mockFetch(401, JSON.stringify({ error: { code: 'UNAUTHORIZED', message: 'nope' } }))
|
||||
const err = await api.get('/api/v1/x').catch((e) => e)
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err).toMatchObject({ status: 401, code: 'UNAUTHORIZED', message: 'nope' })
|
||||
expect(getToken()).toBeNull()
|
||||
})
|
||||
|
||||
it('не-JSON тело ошибки → ApiError, а не падение парсера', async () => {
|
||||
mockFetch(500, '<html>oops</html>')
|
||||
const err = await api.get('/api/v1/x').catch((e) => e)
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err.code).toBe('PARSE_ERROR')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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/)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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/dist \
|
||||
&& cp apps/api/dist/server.js /out/dist/server.js \
|
||||
&& 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"]
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Счётчик «поколения» данных пользователей: растёт при любой записи в
|
||||
* users/user_apps/user_permissions через функции этого пакета. Кэши верхнего
|
||||
* уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу.
|
||||
*/
|
||||
let userDataGeneration = 0
|
||||
|
||||
export function getUserDataGeneration(): number {
|
||||
return userDataGeneration
|
||||
}
|
||||
|
||||
export function bumpUserDataGeneration(): void {
|
||||
userDataGeneration += 1
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendAuditBatch,
|
||||
closeDb,
|
||||
createDb,
|
||||
createUser,
|
||||
getUserApps,
|
||||
getUserById,
|
||||
listActiveSessions,
|
||||
listAudit,
|
||||
migrateSchema,
|
||||
runHousekeeping,
|
||||
searchDirectoryUsers,
|
||||
} from '../../src/index.js'
|
||||
|
||||
const { db, sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
afterAll(() => closeDb(sqlite))
|
||||
|
||||
function timed<T>(fn: () => T): { ms: number; value: T } {
|
||||
const t0 = performance.now()
|
||||
const value = fn()
|
||||
return { ms: performance.now() - t0, value }
|
||||
}
|
||||
|
||||
// Бюджеты щедрые (CI-раннеры шумные): ловим деградацию на порядок, а не на проценты.
|
||||
describe('бюджеты производительности БД', () => {
|
||||
const users = Array.from({ length: 200 }, (_, i) =>
|
||||
createUser(db, {
|
||||
email: `u${i}@x.io`,
|
||||
name: `User ${i}`,
|
||||
passwordHash: 'h',
|
||||
isAdmin: false,
|
||||
}),
|
||||
)
|
||||
|
||||
it('вставка 100k сессий и фильтр активных по пользователю < 150 мс', () => {
|
||||
const ins = sqlite.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
)
|
||||
const future = new Date(Date.now() + 86_400_000).toISOString()
|
||||
const past = '2000-01-01T00:00:00.000Z'
|
||||
sqlite.transaction(() => {
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
ins.run(`s${i}`, users[i % users.length]!.id, `h${i}`, i % 10 === 0 ? future : past, `t${i}`)
|
||||
}
|
||||
})()
|
||||
listActiveSessions(db, { userId: users[0]!.id }) // прогрев
|
||||
const { ms, value } = timed(() => listActiveSessions(db, { userId: users[0]!.id }))
|
||||
console.log(`listActiveSessions(user) на 100k: ${ms.toFixed(1)} мс, ${value.length} строк`)
|
||||
expect(value.length).toBeGreaterThan(0)
|
||||
expect(ms).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('housekeeping 100k истёкших сессий < 3 с', () => {
|
||||
const { ms, value } = timed(() => runHousekeeping(db, { retentionDays: 1 }))
|
||||
console.log(`runHousekeeping: ${ms.toFixed(0)} мс, удалено ${value.sessions}`)
|
||||
expect(value.sessions).toBeGreaterThan(80_000)
|
||||
expect(ms).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('пакетный ingest 10k событий одной транзакцией < 1.5 с', () => {
|
||||
const events = Array.from({ length: 10_000 }, (_, i) => ({
|
||||
eventId: `e${i}`,
|
||||
action: i % 2 ? 'auth.login' : 'user.update',
|
||||
summary: 'bench',
|
||||
}))
|
||||
const { ms, value } = timed(() => appendAuditBatch(db, events))
|
||||
console.log(`appendAuditBatch(10k): ${ms.toFixed(0)} мс`)
|
||||
expect(value.accepted).toBe(10_000)
|
||||
expect(ms).toBeLessThan(1500)
|
||||
})
|
||||
|
||||
it('listAudit по kind использует индекс: < 50 мс на 10k', () => {
|
||||
listAudit(db, { kind: 'logins', limit: 100 })
|
||||
const { ms, value } = timed(() => listAudit(db, { kind: 'logins', limit: 100 }))
|
||||
console.log(`listAudit(kind=logins): ${ms.toFixed(1)} мс`)
|
||||
expect(value.length).toBe(100)
|
||||
expect(ms).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('10k горячих запросов профиля (prepared) < 400 мс', () => {
|
||||
const id = users[0]!.id
|
||||
const { ms } = timed(() => {
|
||||
for (let i = 0; i < 10_000; i++) {
|
||||
getUserById(db, id)
|
||||
getUserApps(db, id)
|
||||
}
|
||||
})
|
||||
console.log(`20k prepared-запросов: ${ms.toFixed(0)} мс`)
|
||||
expect(ms).toBeLessThan(400)
|
||||
})
|
||||
|
||||
it('поиск каталога по 200 пользователям < 20 мс', () => {
|
||||
searchDirectoryUsers(db, 'user 1')
|
||||
const { ms } = timed(() => searchDirectoryUsers(db, 'user 1'))
|
||||
expect(ms).toBeLessThan(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
Generated
+785
-116
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose),
|
||||
# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных.
|
||||
#
|
||||
# scripts/ci/smoke-image.sh <image> [port]
|
||||
#
|
||||
# Бюджеты (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> [port]}"
|
||||
PORT="${2:-18080}"
|
||||
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:${PORT}"
|
||||
|
||||
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
|
||||
|
||||
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" -p "${PORT}:8080" \
|
||||
-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 curl -fsS -m 2 "$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() {
|
||||
curl -fsS -m 10 -X POST "$BASE/api/v1/auth/login" \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"email\":\"[email protected]\",\"password\":\"${ADMIN_PW}\"}"
|
||||
}
|
||||
|
||||
# 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" --entrypoint sh "$IMAGE" -c 'chown 1000:1000 /data' >/dev/null 2>&1 || true
|
||||
run_container
|
||||
wait_ready
|
||||
[ "$(curl -fsS -m 3 "$BASE/health")" = '{"ok":true}' ] || fail "/health"
|
||||
login | grep -q '"access_token"' || fail "логин администратора"
|
||||
kid1=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/')
|
||||
[ -n "$kid1" ] || fail "JWKS пуст"
|
||||
code=$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$BASE/oauth/nope")
|
||||
[ "$code" = 404 ] || fail "/oauth/nope вернул $code, ожидался 404"
|
||||
|
||||
# 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=$(curl -fsS -m 3 "$BASE/.well-known/jwks.json" | sed -E 's/.*"kid":"([^"]+)".*/\1/')
|
||||
[ "$kid1" = "$kid2" ] || fail "kid изменился после рестарта ($kid1 → $kid2)"
|
||||
|
||||
echo "SMOKE OK"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user