ci: покрытие и пороги, тесты shared/api, smoke образа и бюджеты, документация
This commit is contained in:
1 parent
22dd6f9937
commit
5cbf4d29d7
15 files changed
+952
-11
No files matched your search
@@ -192,6 +192,8 @@ jobs:
|
|||||||
pnpm --filter web lint
|
pnpm --filter web lint
|
||||||
pnpm exec turbo run typecheck --filter=web
|
pnpm exec turbo run typecheck --filter=web
|
||||||
pnpm exec turbo run build --filter=web
|
pnpm exec turbo run build --filter=web
|
||||||
|
pnpm --filter web test
|
||||||
|
node scripts/ci/web-budget.mjs apps/web/dist/assets
|
||||||
|
|
||||||
api:
|
api:
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
@@ -230,7 +232,7 @@ jobs:
|
|||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
sh scripts/ci/pnpm-ci.sh
|
sh scripts/ci/pnpm-ci.sh
|
||||||
pnpm exec turbo run lint --filter=@authportal/api --filter=@authportal/db --filter=@authportal/shared
|
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 typecheck --filter=@authportal/api
|
||||||
pnpm exec turbo run build --filter=@authportal/api
|
pnpm exec turbo run build --filter=@authportal/api
|
||||||
|
|
||||||
@@ -276,7 +278,9 @@ jobs:
|
|||||||
|
|
||||||
docker-check:
|
docker-check:
|
||||||
needs: [changes]
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -310,3 +314,12 @@ jobs:
|
|||||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
export CACHE_REF_NODE="git.shx.one/${owner_lc}/authportal-buildcache:node-buildcache"
|
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
|
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
|
||||||
@@ -36,3 +36,5 @@ version.txt
|
|||||||
.release-version
|
.release-version
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
coverage
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@authportal/db": "workspace:*",
|
"@authportal/db": "workspace:*",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/node": "^22.15.32",
|
"@types/node": "^22.15.32",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,5 +3,13 @@ import { defineConfig } from 'vitest/config'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
passWithNoTests: true,
|
passWithNoTests: true,
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
reporter: ['text-summary', 'lcov'],
|
||||||
|
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
|
||||||
|
thresholds: { lines: 75 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -31,3 +31,24 @@ NODE_ENV=production
|
|||||||
# WEBAUTHN_RP_ID=auth.shnt.top
|
# WEBAUTHN_RP_ID=auth.shnt.top
|
||||||
# WEBAUTHN_ORIGINS=https://auth.shnt.top
|
# WEBAUTHN_ORIGINS=https://auth.shnt.top
|
||||||
# WEBAUTHN_RP_NAME=Auth Portal
|
# 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
|
||||||
@@ -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 не поддерживает несколько писателей.
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:push": "drizzle-kit push"
|
"db:push": "drizzle-kit push"
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.1",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"build": "tsup src/index.ts --format esm --dts",
|
"build": "tsup src/index.ts --format esm --dts",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"eslint": "^10.7.0",
|
"eslint": "^10.7.0",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
"tsup": "^8.5.0",
|
"tsup": "^8.5.0",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+109
-7
@@ -103,6 +103,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.15.32
|
specifier: ^22.15.32
|
||||||
version: 22.20.1
|
version: 22.20.1
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10([email protected])
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.7.0
|
specifier: ^10.7.0
|
||||||
version: 10.7.0([email protected])
|
version: 10.7.0([email protected])
|
||||||
@@ -123,7 +126,7 @@ importers:
|
|||||||
version: 8.64.0([email protected]([email protected]))([email protected])
|
version: 8.64.0([email protected]([email protected]))([email protected])
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -256,7 +259,7 @@ importers:
|
|||||||
version: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
|
version: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
packages/db:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -276,6 +279,9 @@ importers:
|
|||||||
'@types/better-sqlite3':
|
'@types/better-sqlite3':
|
||||||
specifier: ^7.6.13
|
specifier: ^7.6.13
|
||||||
version: 7.6.13
|
version: 7.6.13
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10([email protected])
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.1
|
specifier: ^0.31.1
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
@@ -296,7 +302,7 @@ importers:
|
|||||||
version: 8.64.0([email protected]([email protected]))([email protected])
|
version: 8.64.0([email protected]([email protected]))([email protected])
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -307,6 +313,9 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^10.0.1
|
specifier: ^10.0.1
|
||||||
version: 10.0.1([email protected]([email protected]))
|
version: 10.0.1([email protected]([email protected]))
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10([email protected])
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.7.0
|
specifier: ^10.7.0
|
||||||
version: 10.7.0([email protected])
|
version: 10.7.0([email protected])
|
||||||
@@ -324,7 +333,7 @@ importers:
|
|||||||
version: 8.64.0([email protected]([email protected]))([email protected])
|
version: 8.64.0([email protected]([email protected]))([email protected])
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 'catalog:'
|
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:
|
packages/ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -481,6 +490,10 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@bcoe/[email protected]':
|
||||||
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@colors/[email protected]':
|
'@colors/[email protected]':
|
||||||
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
|
||||||
engines: {node: '>=0.1.90'}
|
engines: {node: '>=0.1.90'}
|
||||||
@@ -2238,6 +2251,15 @@ packages:
|
|||||||
babel-plugin-react-compiler:
|
babel-plugin-react-compiler:
|
||||||
optional: true
|
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]':
|
'@vitest/[email protected]':
|
||||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||||
|
|
||||||
@@ -2361,6 +2383,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
@@ -3269,6 +3294,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
|
resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
|
||||||
|
|
||||||
@@ -3427,6 +3455,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==}
|
resolution: {integrity: sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==}
|
||||||
engines: {node: ^18.17 || >=20.6.1}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
|
resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -3450,6 +3490,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -3675,10 +3718,17 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
|
resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==}
|
resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -5164,6 +5214,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
|
'@bcoe/[email protected]': {}
|
||||||
|
|
||||||
'@colors/[email protected]':
|
'@colors/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6749,6 +6801,20 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.1
|
'@rolldown/pluginutils': 1.0.1
|
||||||
vite: 8.1.5(@types/[email protected])([email protected])([email protected])([email protected])
|
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]':
|
'@vitest/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -6892,6 +6958,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -7857,6 +7929,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 11.5.2
|
lru-cache: 11.5.2
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -7984,6 +8058,19 @@ snapshots:
|
|||||||
lodash.isstring: 4.0.1
|
lodash.isstring: 4.0.1
|
||||||
lodash.uniqby: 4.7.0
|
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]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 9.0.0
|
'@isaacs/cliui': 9.0.0
|
||||||
@@ -7998,6 +8085,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
@@ -8176,12 +8265,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-event: 6.0.1
|
p-event: 6.0.1
|
||||||
type-fest: 4.41.0
|
type-fest: 4.41.0
|
||||||
web-worker: 1.5.0
|
web-worker: 1.5.0
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
dependencies:
|
||||||
|
semver: 7.8.5
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-escapes: 7.3.0
|
ansi-escapes: 7.3.0
|
||||||
@@ -9245,7 +9344,7 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.1
|
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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
'@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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.1
|
'@types/node': 22.20.1
|
||||||
|
'@vitest/coverage-v8': 4.1.10([email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- 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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
'@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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
'@vitest/coverage-v8': 4.1.10([email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- 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:
|
dependencies:
|
||||||
'@vitest/expect': 4.1.10
|
'@vitest/expect': 4.1.10
|
||||||
'@vitest/mocker': 4.1.10([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
|
'@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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
'@vitest/coverage-v8': 4.1.10([email protected])
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- msw
|
- msw
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
@@ -14,6 +14,9 @@
|
|||||||
"outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"]
|
"outputs": ["**/*.tsbuildinfo", "node_modules/.tmp/**"]
|
||||||
},
|
},
|
||||||
"test": {},
|
"test": {},
|
||||||
|
"test:coverage": {
|
||||||
|
"outputs": ["coverage/**"]
|
||||||
|
},
|
||||||
"lint": {}
|
"lint": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user