ci: покрытие и пороги, тесты shared/api, smoke образа и бюджеты, документация

This commit is contained in:
Denozordec committed 2026-10-23 17:08:36 +07:00
1 parent 22dd6f9937
commit 5cbf4d29d7
15 files changed
+952 -11

No files matched your search

+15 -2
View File
@@ -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
+2
View File
@@ -36,3 +36,5 @@ version.txt
.release-version
CHANGELOG.md
.claude/settings.local.json
coverage
+3 -1
View File
@@ -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",
+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = '[email protected]', password = 'adminpass') {
const res = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password },
})
return res
}
describe('auth', () => {
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
app = await buildTestApp()
const token = await adminToken(app)
expect((await login(app, '[email protected]', 'wrong')).statusCode).toBe(401)
expect((await login(app, '[email protected]', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Off',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const id = (created.json() as { id: string }).id
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { disabled: true },
})
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
})
it('me / reissue / catalog / logout по cookie', async () => {
app = await buildTestApp()
const res = await login(app)
const token = (res.json() as { access_token: string }).access_token
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
expect(me.statusCode).toBe(200)
expect((me.json() as { email: string }).email).toBe('[email protected]')
const reissue = await app.inject({
method: 'POST',
url: '/api/v1/auth/reissue',
headers: auth(token),
})
expect(reissue.statusCode).toBe(200)
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
expect(catalog.statusCode).toBe(200)
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
expect(noAuth.statusCode).toBe(401)
const logout = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
})
expect(logout.statusCode).toBe(200)
const revoked = app.sqlite
.prepare('SELECT revoked_at FROM refresh_sessions')
.get() as { revoked_at: string | null }
expect(revoked.revoked_at).toBeTruthy()
})
})
describe('admin users', () => {
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const payload = {
email: '[email protected]',
name: 'Dup',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
}
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(first.statusCode).toBe(201)
const id = (first.json() as { id: string }).id
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(dup.statusCode).toBe(409)
const bad = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: { email: 'nope' },
})
expect(bad.statusCode).toBe(400)
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { name: 'Renamed', password: 'newsecret12' },
})
expect(patched.statusCode).toBe(200)
expect((patched.json() as { name: string }).name).toBe('Renamed')
expect((await login(app, '[email protected]', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, '[email protected]', 'secret12')).statusCode).toBe(401)
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(del.statusCode).toBe(204)
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(gone.statusCode).toBe(404)
})
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
const myId = (me.json() as { id: string }).id
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
expect(self.statusCode).toBe(400)
await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: '[email protected]',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, '[email protected]', 'secret12')).json() as { access_token: string }).access_token
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
expect(forbidden.statusCode).toBe(403)
})
})
describe('OIDC клиенты и обмен кода', () => {
async function setup(a: App) {
const token = await adminToken(a)
const created = await a.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: auth(token),
payload: {
name: 'RP',
redirect_uris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as { id: string; client_id: string; client_secret: string }
const loginRes = await login(a)
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
return { token, client, refresh }
}
async function authorizeCode(
a: App,
refresh: string,
clientId: string,
extra: Record<string, string> = {},
): Promise<string> {
const res = await a.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: clientId,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid profile',
...extra,
},
})
expect(res.statusCode).toBe(302)
return new URL(res.headers.location as string).searchParams.get('code')!
}
const exchange = (a: App, body: Record<string, string>) =>
a.inject({
method: 'POST',
url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({
grant_type: 'authorization_code',
redirect_uri: 'https://rp.test.local/cb',
...body,
}).toString(),
})
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const stored = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients')
.get() as { h: string }
expect(stored.h.startsWith('sha256:')).toBe(true)
const code = await authorizeCode(app, refresh, client.client_id)
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(ok.statusCode).toBe(200)
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
const replay = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(replay.statusCode).toBe(400)
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
})
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const verifier = 'v'.repeat(50)
const challenge = createHash('sha256').update(verifier).digest('base64url')
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
const creds = { client_id: client.client_id, client_secret: client.client_secret }
const good = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: verifier,
})
expect(good.statusCode).toBe(200)
const wrong = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: 'x'.repeat(50),
})
expect(wrong.statusCode).toBe(400)
const missing = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
})
expect(missing.statusCode).toBe(400)
const plain = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, {
code_challenge: 'plain-challenge',
code_challenge_method: 'plain',
}),
code_verifier: 'plain-challenge',
})
expect(plain.statusCode).toBe(200)
})
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const code = await authorizeCode(app, refresh, client.client_id)
const badSecret = await exchange(app, {
client_id: client.client_id,
client_secret: 'nope',
code,
})
expect(badSecret.statusCode).toBe(401)
const badRedirect = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://evil.test/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(badRedirect.statusCode).toBe(400)
const badScope = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid admin',
},
})
expect(badScope.statusCode).toBe(302)
expect(badScope.headers.location).toContain('error=invalid_scope')
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
const accessToken = (ok.json() as { access_token: string }).access_token
const info = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: auth(accessToken),
})
expect(info.statusCode).toBe(200)
expect((info.json() as { email: string }).email).toBe('[email protected]')
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
expect(noToken.statusCode).toBe(401)
})
it('без сессии authorize редиректит на логин', async () => {
app = await buildTestApp()
const { client } = await setup(app)
const res = await app.inject({
method: 'GET',
url: '/oauth/authorize',
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toContain('return_to=')
})
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
app = await buildTestApp()
const { refresh } = await setup(app)
const secret = generateOidcClientSecret()
const legacy = createOidcClient(app.db, {
name: 'Legacy',
redirectUris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile'],
enabled: true,
clientSecretHash: await hash(secret),
})
const code = await authorizeCode(app, refresh, legacy.clientId)
const res = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code,
})
expect(res.statusCode).toBe(200)
const row = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
.get(legacy.clientId) as { h: string }
expect(row.h.startsWith('sha256:')).toBe(true)
// после миграции секрет всё ещё работает
const code2 = await authorizeCode(app, refresh, legacy.clientId)
const again = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code: code2,
})
expect(again.statusCode).toBe(200)
})
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
app = await buildTestApp()
const { token, client, refresh } = await setup(app)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
payload: { name: 'Renamed RP' },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
const rotate = await app.inject({
method: 'POST',
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
headers: auth(token),
})
expect(rotate.statusCode).toBe(200)
const fresh = (rotate.json() as { client_secret: string }).client_secret
expect(fresh).not.toBe(client.client_secret)
const code = await authorizeCode(app, refresh, client.client_id)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
).toBe(401)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
).toBe(200)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(del.statusCode).toBe(200)
const gone = await app.inject({
method: 'GET',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(gone.statusCode).toBe(404)
})
})
+8
View File
@@ -3,5 +3,13 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 75 },
},
},
})
+21
View File
@@ -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
+35
View File
@@ -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 <image> # размер, 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 не поддерживает несколько писателей.
+2
View File
@@ -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",
+11 -1
View File
@@ -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 },
},
},
})
+2
View File
@@ -18,6 +18,7 @@
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
@@ -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",
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from 'vitest'
import {
APP_IDS,
allPermissionKeys,
appsMetaFromSwitcher,
buildSsoRedirectUrl,
createOidcClientRequestSchema,
defaultAppSwitcherConfig,
hasPermission,
isJwtExpired,
isPortalOidcAuthorizeUrl,
isReturnToAllowed,
loginRequestSchema,
normalizeAppSwitcherConfig,
normalizePermissionKeys,
oidcGroupsForUser,
parseAppSwitcherConfig,
permissionKey,
publicAppSwitcherConfig,
readJwtExp,
readJwtPayload,
tenantsClaimForUser,
} from '../src/index.js'
function jwt(payload: object): string {
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('permissions', () => {
it('иерархия admin ⊃ write ⊃ read в пределах секции', () => {
expect(hasPermission(['dns:dns:admin'], 'dns:dns:write')).toBe(true)
expect(hasPermission(['dns:dns:admin'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:write'], 'dns:dns:read')).toBe(true)
expect(hasPermission(['dns:dns:read'], 'dns:dns:write')).toBe(false)
expect(hasPermission(['dns:dns:admin'], 'dns:dhcp:read')).toBe(false)
expect(hasPermission([], 'broken')).toBe(false)
})
it('permissionKey / allPermissionKeys согласованы', () => {
expect(permissionKey('dns', 'dns', 'read')).toBe('dns:dns:read')
const all = allPermissionKeys()
expect(new Set(all).size).toBe(all.length)
expect(all).toContain('dns:dns:read')
})
it('normalizePermissionKeys: легаси мапятся, неизвестные отбрасываются, результат отсортирован', () => {
const out = normalizePermissionKeys([
'bgp:peers:read',
'bgp:settings:admin',
'nope:x:read',
'dns:dns:read',
'dns:dns:read',
])
expect(out).toEqual([...out].sort())
expect(out).toContain('bgp:network:read')
expect(out).toContain('bgp:tenant_settings:admin')
expect(out).toContain('dns:dns:read')
expect(out).not.toContain('nope:x:read')
expect(out.filter((k) => k === 'dns:dns:read')).toHaveLength(1)
})
})
describe('oidcGroupsForUser', () => {
it('админ получает все группы Technitium', () => {
expect(oidcGroupsForUser({ isAdmin: true, permissions: [] })).toEqual([
'technitium_admins',
'technitium_dns_admins',
'technitium_dhcp_admins',
])
})
it('права dns/dhcp дают соответствующие группы', () => {
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dns:write'] }),
).toEqual(['technitium_dns_admins'])
expect(
oidcGroupsForUser({ isAdmin: false, permissions: ['dns:dhcp:admin'] }),
).toEqual(['technitium_dhcp_admins'])
expect(oidcGroupsForUser({ isAdmin: false, permissions: [] })).toEqual([])
})
})
describe('isReturnToAllowed', () => {
it.each([
['https://cfdm.shnt.top/x', '.shnt.top', true],
['https://shnt.top', '.shnt.top', true],
['https://evilshnt.top', '.shnt.top', false],
['http://localhost:5173/cb', 'localhost', true],
['http://192.168.1.5/x', 'private', true],
['http://172.32.0.1/x', 'private', false],
['https://8.8.8.8', 'private', false],
['http://localhost:5173/a', 'http://localhost:5173', true],
['http://localhost:5174/a', 'http://localhost:5173', false],
['javascript:alert(1)', 'localhost', false],
['not a url', 'localhost', false],
])('%s vs %s → %s', (url, list, expected) => {
expect(isReturnToAllowed(url, list)).toBe(expected)
})
})
describe('JWT helpers', () => {
it('читает payload и exp, определяет протухание с запасом 30 c', () => {
const exp = Math.floor(Date.now() / 1000) + 3600
const t = jwt({ sub: 'u1', exp })
expect(readJwtPayload(t)).toMatchObject({ sub: 'u1' })
expect(readJwtExp(t)).toBe(exp)
expect(isJwtExpired(t)).toBe(false)
expect(isJwtExpired(jwt({ exp: 1 }))).toBe(true)
const justExpired = jwt({ exp: Math.floor(Date.now() / 1000) - 10 })
expect(isJwtExpired(justExpired)).toBe(false)
expect(isJwtExpired(jwt({ sub: 'no-exp' }))).toBe(true)
expect(readJwtPayload('garbage')).toBeNull()
expect(readJwtExp('a.b')).toBeNull()
})
it('buildSsoRedirectUrl кладёт токен во фрагмент и заменяет старый', () => {
const url = buildSsoRedirectUrl('https://a.shnt.top/p?x=1#old', 'T', 'E')
expect(url.startsWith('https://a.shnt.top/p?x=1#')).toBe(true)
expect(url).toContain('access_token=T')
expect(url).not.toContain('old')
})
})
describe('app switcher', () => {
it('default содержит все приложения', () => {
const d = defaultAppSwitcherConfig()
expect(d.apps.map((a) => a.id).sort()).toEqual([...APP_IDS].sort())
})
it('parseAppSwitcherConfig: мусор → default, неполный конфиг дополняется', () => {
expect(parseAppSwitcherConfig({ nope: 1 })).toEqual(defaultAppSwitcherConfig())
const partial = parseAppSwitcherConfig({
menuLabel: 'Меню',
apps: [
{
id: 'dns',
name: 'DNS!',
url: 'https://dns.example.com',
icon: 'globe',
enabled: false,
tenantId: ' ',
},
],
})
expect(partial.menuLabel).toBe('Меню')
expect(partial.apps).toHaveLength(APP_IDS.length)
const dns = partial.apps.find((a) => a.id === 'dns')!
expect(dns.name).toBe('DNS!')
expect(dns.enabled).toBe(false)
expect(dns.tenantId).toBeUndefined()
})
it('public-конфиг без tenantId; tenants-claim только по доступным приложениям', () => {
const base = defaultAppSwitcherConfig()
const cfg = normalizeAppSwitcherConfig({
...base,
apps: base.apps.map((a) => (a.id === 'bgp' ? { ...a, tenantId: 'tenant-1' } : a)),
})
expect(JSON.stringify(publicAppSwitcherConfig(cfg))).not.toContain('tenant-1')
expect(tenantsClaimForUser(cfg, ['bgp'])).toEqual({ bgp: 'tenant-1' })
expect(tenantsClaimForUser(cfg, ['dns'])).toEqual({})
})
it('appsMetaFromSwitcher скрывает отключённые приложения', () => {
const cfg = defaultAppSwitcherConfig()
cfg.apps = cfg.apps.map((a) => (a.id === 'dns' ? { ...a, enabled: false } : a))
expect(appsMetaFromSwitcher(cfg).some((a) => a.id === 'dns')).toBe(false)
})
})
describe('zod-схемы', () => {
it('login: email и непустой пароль обязательны', () => {
expect(loginRequestSchema.safeParse({ email: '[email protected]', password: 'x' }).success).toBe(true)
expect(loginRequestSchema.safeParse({ email: 'bad', password: 'x' }).success).toBe(false)
expect(loginRequestSchema.safeParse({ email: '[email protected]', password: '' }).success).toBe(false)
})
it('createOidcClient: scopes по умолчанию, redirect_uris не пустой', () => {
const ok = createOidcClientRequestSchema.parse({
name: 'c',
redirect_uris: ['https://x.example/cb'],
})
expect(ok.scopes).toContain('openid')
expect(ok.enabled).toBe(true)
expect(
createOidcClientRequestSchema.safeParse({ name: 'c', redirect_uris: [] }).success,
).toBe(false)
})
it('isPortalOidcAuthorizeUrl', () => {
const iss = 'https://auth.shnt.top'
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize?x=1`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl(`${iss}/oauth/authorize/`, iss)).toBe(true)
expect(isPortalOidcAuthorizeUrl('https://evil.example/oauth/authorize', iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl(`${iss}/other`, iss)).toBe(false)
expect(isPortalOidcAuthorizeUrl('nope', iss)).toBe(false)
})
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 90 },
},
},
})
+109 -7
View File
@@ -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([email protected])
eslint:
specifier: ^10.7.0
version: 10.7.0([email protected])
@@ -123,7 +126,7 @@ importers:
version: 8.64.0([email protected]([email protected]))([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
apps/web:
dependencies:
@@ -256,7 +259,7 @@ importers:
version: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
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([email protected])
drizzle-kit:
specifier: ^0.31.1
version: 0.31.10
@@ -296,7 +302,7 @@ importers:
version: 8.64.0([email protected]([email protected]))([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
packages/shared:
dependencies:
@@ -307,6 +313,9 @@ importers:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1([email protected]([email protected]))
'@vitest/coverage-v8':
specifier: 4.1.10
version: 4.1.10([email protected])
eslint:
specifier: ^10.7.0
version: 10.7.0([email protected])
@@ -324,7 +333,7 @@ importers:
version: 8.64.0([email protected]([email protected]))([email protected])
vitest:
specifier: 'catalog:'
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
version: 4.1.10(@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
packages/ui:
dependencies:
@@ -481,6 +490,10 @@ packages:
'@types/react':
optional: true
'@bcoe/[email protected]':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@colors/[email protected]':
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -2238,6 +2251,15 @@ packages:
babel-plugin-react-compiler:
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==}
peerDependencies:
'@vitest/browser': 4.1.10
vitest: 4.1.10
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/[email protected]':
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
@@ -2361,6 +2383,9 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
[email protected]:
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}
[email protected]:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
[email protected]:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -3427,6 +3455,18 @@ packages:
resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==}
engines: {node: ^18.17 || >=20.6.1}
[email protected]:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
engines: {node: 20 || >=22}
@@ -3450,6 +3490,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3675,10 +3718,17 @@ packages:
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
[email protected]:
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
[email protected]:
resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
[email protected]:
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/[email protected]': {}
'@colors/[email protected]':
optional: true
@@ -6749,6 +6801,20 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
'@vitest/[email protected]([email protected])':
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/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
'@vitest/[email protected]':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -6892,6 +6958,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
js-tokens: 10.0.0
[email protected]: {}
[email protected]: {}
@@ -7857,6 +7929,8 @@ snapshots:
dependencies:
lru-cache: 11.5.2
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -7984,6 +8058,19 @@ snapshots:
lodash.isstring: 4.0.1
lodash.uniqby: 4.7.0
[email protected]: {}
[email protected]:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
[email protected]:
dependencies:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
[email protected]:
dependencies:
'@isaacs/cliui': 9.0.0
@@ -7998,6 +8085,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -8176,12 +8265,22 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
[email protected]:
dependencies:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
source-map-js: 1.2.1
[email protected]:
dependencies:
p-event: 6.0.1
type-fest: 4.41.0
web-worker: 1.5.0
[email protected]:
dependencies:
semver: 7.8.5
[email protected]([email protected]):
dependencies:
ansi-escapes: 7.3.0
@@ -9245,7 +9344,7 @@ snapshots:
jiti: 2.7.0
tsx: 4.23.1
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -9269,10 +9368,11 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.1
'@vitest/coverage-v8': 4.1.10([email protected])
transitivePeerDependencies:
- msw
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -9296,10 +9396,11 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
'@vitest/coverage-v8': 4.1.10([email protected])
transitivePeerDependencies:
- msw
[email protected](@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
[email protected](@types/[email protected])(@vitest/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
@@ -9323,6 +9424,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
'@vitest/coverage-v8': 4.1.10([email protected])
transitivePeerDependencies:
- msw
+96
View File
@@ -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"
+3
View File
@@ -14,6 +14,9 @@
"outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"]
},
"test": {},
"test:coverage": {
"outputs": ["coverage/**"]
},
"lint": {}
}
}