From 5cbf4d29d739b37d2b96dc356efbe966b480df19 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 23 Oct 2026 17:08:36 +0700 Subject: [PATCH] =?UTF-8?q?ci:=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B8=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=D0=B8,?= =?UTF-8?q?=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20shared/api,=20smoke=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=B0=20=D0=B8=20=D0=B1=D1=8E?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=82=D1=8B,=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/quality.yaml | 17 +- .gitignore | 2 + apps/api/package.json | 4 +- apps/api/test/api-coverage.test.ts | 431 +++++++++++++++++++++++++ apps/api/vitest.config.ts | 8 + deploy/env.traefik.example | 21 ++ docs/performance.md | 35 ++ packages/db/package.json | 2 + packages/db/vitest.config.ts | 12 +- packages/shared/package.json | 2 + packages/shared/test/contracts.test.ts | 199 ++++++++++++ packages/shared/vitest.config.ts | 15 + pnpm-lock.yaml | 116 ++++++- scripts/ci/smoke-image.sh | 96 ++++++ turbo.json | 3 + 15 files changed, 952 insertions(+), 11 deletions(-) create mode 100644 apps/api/test/api-coverage.test.ts create mode 100644 docs/performance.md create mode 100644 packages/shared/test/contracts.test.ts create mode 100644 packages/shared/vitest.config.ts create mode 100755 scripts/ci/smoke-image.sh diff --git a/.gitea/workflows/quality.yaml b/.gitea/workflows/quality.yaml index a70150f..401f31b 100644 --- a/.gitea/workflows/quality.yaml +++ b/.gitea/workflows/quality.yaml @@ -192,6 +192,8 @@ jobs: 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] @@ -230,7 +232,7 @@ jobs: 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 @@ -276,7 +278,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 +314,12 @@ 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 diff --git a/.gitignore b/.gitignore index b45eefc..2c3206a 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ version.txt .release-version CHANGELOG.md .claude/settings.local.json + +coverage diff --git a/apps/api/package.json b/apps/api/package.json index 65fd2ab..7cc791d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,7 +9,8 @@ "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:*", @@ -29,6 +30,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^22.15.32", + "@vitest/coverage-v8": "4.1.10", "eslint": "^10.7.0", "globals": "^17.7.0", "tsup": "^8.5.0", diff --git a/apps/api/test/api-coverage.test.ts b/apps/api/test/api-coverage.test.ts new file mode 100644 index 0000000..fbc19cf --- /dev/null +++ b/apps/api/test/api-coverage.test.ts @@ -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> +let app: App | undefined +afterEach(async () => { + await app?.close() + app = undefined +}) + +const auth = (token: string) => ({ authorization: `Bearer ${token}` }) + +async function login(a: App, email = 'admin@test.local', 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, 'admin@test.local', 'wrong')).statusCode).toBe(401) + expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401) + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/admin/users', + headers: auth(token), + payload: { + email: 'off@test.local', + 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, 'off@test.local', '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('admin@test.local') + + 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: 'dup@test.local', + 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, 'dup@test.local', 'newsecret12')).statusCode).toBe(200) + expect((await login(app, 'dup@test.local', '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: 'plain@test.local', + name: 'Plain', + password: 'secret12', + is_admin: false, + apps: ['cfdm'], + permissions: [], + }, + }) + const userTok = ((await login(app, 'plain@test.local', '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 = {}, + ): Promise { + 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) => + 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('admin@test.local') + 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) + }) +}) diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index a8627e8..d93d217 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -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 }, + }, }, }) diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example index 0f7c56a..0813396 100644 --- a/deploy/env.traefik.example +++ b/deploy/env.traefik.example @@ -31,3 +31,24 @@ 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 +# Доп. 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 diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..7aaef2e --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,35 @@ +# Производительность и ресурсы контейнера + +Что сделано для быстродействия, стабильности и минимального потребления +ресурсов, и как это проверять. + +## Что включено + +| Область | Решение | +|---|---| +| Остановка | 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`, ротация. | + +## Проверка + +```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 # размер, RSS, /ready, docker stop, данные после рестарта +``` + +Бюджеты 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 не поддерживает несколько писателей. diff --git a/packages/db/package.json b/packages/db/package.json index c7de663..03853b2 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -20,6 +20,7 @@ "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage", "dev": "tsup src/index.ts --format esm --dts --watch", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push" @@ -32,6 +33,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@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", diff --git a/packages/db/vitest.config.ts b/packages/db/vitest.config.ts index aa18381..dbcfeba 100644 --- a/packages/db/vitest.config.ts +++ b/packages/db/vitest.config.ts @@ -1,5 +1,15 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ - test: { passWithNoTests: true, include: ['test/**/*.test.ts'] }, + test: { + passWithNoTests: true, + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + reporter: ['text-summary', 'lcov'], + // Стартовые пороги: поднимать по мере роста покрытия, не снижать. + thresholds: { lines: 50 }, + }, + }, }) diff --git a/packages/shared/package.json b/packages/shared/package.json index 0a64850..5d63562 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" }, @@ -26,6 +27,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@vitest/coverage-v8": "4.1.10", "eslint": "^10.7.0", "globals": "^17.7.0", "tsup": "^8.5.0", diff --git a/packages/shared/test/contracts.test.ts b/packages/shared/test/contracts.test.ts new file mode 100644 index 0000000..b763fe7 --- /dev/null +++ b/packages/shared/test/contracts.test.ts @@ -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: 'a@b.co', password: 'x' }).success).toBe(true) + expect(loginRequestSchema.safeParse({ email: 'bad', password: 'x' }).success).toBe(false) + expect(loginRequestSchema.safeParse({ email: 'a@b.co', 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) + }) +}) diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..eec6308 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -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 }, + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e013885..c109489 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: '@types/node': specifier: ^22.15.32 version: 22.20.1 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) @@ -123,7 +126,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) apps/web: dependencies: @@ -256,7 +259,7 @@ importers: version: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages/db: dependencies: @@ -276,6 +279,9 @@ importers: '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) drizzle-kit: specifier: ^0.31.1 version: 0.31.10 @@ -296,7 +302,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) packages/shared: dependencies: @@ -307,6 +313,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.7.0(jiti@2.7.0)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) eslint: specifier: ^10.7.0 version: 10.7.0(jiti@2.7.0) @@ -324,7 +333,7 @@ importers: version: 8.64.0(eslint@10.7.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) + version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) packages/ui: dependencies: @@ -481,6 +490,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2238,6 +2251,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -2361,6 +2383,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3269,6 +3294,9 @@ packages: resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3427,6 +3455,18 @@ packages: resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==} engines: {node: ^18.17 || >=20.6.1} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -3450,6 +3490,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3675,10 +3718,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + make-asynchronous@1.1.0: resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} engines: {node: '>=18'} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked-terminal@7.3.0: resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} engines: {node: '>=16.0.0'} @@ -5164,6 +5214,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@bcoe/v8-coverage@1.0.2': {} + '@colors/colors@1.5.0': optional: true @@ -6749,6 +6801,20 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1) + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.7 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.5 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -6892,6 +6958,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -7857,6 +7929,8 @@ snapshots: dependencies: lru-cache: 11.5.2 + html-escaper@2.0.2: {} + http-cache-semantics@4.2.0: {} http-errors@2.0.1: @@ -7984,6 +8058,19 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 @@ -7998,6 +8085,8 @@ snapshots: joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -8176,12 +8265,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + make-asynchronous@1.1.0: dependencies: p-event: 6.0.1 type-fest: 4.41.0 web-worker: 1.5.0 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + marked-terminal@7.3.0(marked@15.0.12): dependencies: ansi-escapes: 7.3.0 @@ -9245,7 +9344,7 @@ snapshots: jiti: 2.7.0 tsx: 4.23.1 - vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@22.20.1)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -9269,10 +9368,11 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)) @@ -9296,10 +9396,11 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): + vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)) @@ -9323,6 +9424,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/scripts/ci/smoke-image.sh b/scripts/ci/smoke-image.sh new file mode 100755 index 0000000..53fdbac --- /dev/null +++ b/scripts/ci/smoke-image.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Smoke-тест собранного образа: запуск с боевыми ограничениями (как в compose), +# /ready, логин, JWKS, бюджеты RSS/размера, время docker stop, сохранность данных. +# +# scripts/ci/smoke-image.sh [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 [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=admin@smoke.local -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\":\"admin@smoke.local\",\"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" diff --git a/turbo.json b/turbo.json index ae89dcf..917cc2e 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,9 @@ "outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"] }, "test": {}, + "test:coverage": { + "outputs": ["coverage/**"] + }, "lint": {} } }