fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -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 = 'admin@test.local', password = 'adminpass') {
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email, password },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
describe('auth', () => {
|
||||
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
expect((await login(app, 'admin@test.local', 'wrong')).statusCode).toBe(401)
|
||||
expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'off@test.local',
|
||||
name: 'Off',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const id = (created.json() as { id: string }).id
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { disabled: true },
|
||||
})
|
||||
expect((await login(app, 'off@test.local', 'secret12')).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('me / reissue / catalog / logout по cookie', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await login(app)
|
||||
const token = (res.json() as { access_token: string }).access_token
|
||||
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect((me.json() as { email: string }).email).toBe('admin@test.local')
|
||||
|
||||
const reissue = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reissue',
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(reissue.statusCode).toBe(200)
|
||||
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
|
||||
|
||||
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
|
||||
expect(catalog.statusCode).toBe(200)
|
||||
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
|
||||
|
||||
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
|
||||
expect(noAuth.statusCode).toBe(401)
|
||||
|
||||
const logout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
|
||||
})
|
||||
expect(logout.statusCode).toBe(200)
|
||||
const revoked = app.sqlite
|
||||
.prepare('SELECT revoked_at FROM refresh_sessions')
|
||||
.get() as { revoked_at: string | null }
|
||||
expect(revoked.revoked_at).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin users', () => {
|
||||
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const payload = {
|
||||
email: 'dup@test.local',
|
||||
name: 'Dup',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
}
|
||||
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(first.statusCode).toBe(201)
|
||||
const id = (first.json() as { id: string }).id
|
||||
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(dup.statusCode).toBe(409)
|
||||
const bad = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: { email: 'nope' },
|
||||
})
|
||||
expect(bad.statusCode).toBe(400)
|
||||
|
||||
const patched = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed', password: 'newsecret12' },
|
||||
})
|
||||
expect(patched.statusCode).toBe(200)
|
||||
expect((patched.json() as { name: string }).name).toBe('Renamed')
|
||||
expect((await login(app, 'dup@test.local', 'newsecret12')).statusCode).toBe(200)
|
||||
expect((await login(app, 'dup@test.local', 'secret12')).statusCode).toBe(401)
|
||||
|
||||
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(del.statusCode).toBe(204)
|
||||
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
const myId = (me.json() as { id: string }).id
|
||||
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
|
||||
expect(self.statusCode).toBe(400)
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'plain@test.local',
|
||||
name: 'Plain',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const userTok = ((await login(app, 'plain@test.local', 'secret12')).json() as { access_token: string }).access_token
|
||||
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
|
||||
expect(forbidden.statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OIDC клиенты и обмен кода', () => {
|
||||
async function setup(a: App) {
|
||||
const token = await adminToken(a)
|
||||
const created = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
name: 'RP',
|
||||
redirect_uris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as { id: string; client_id: string; client_secret: string }
|
||||
const loginRes = await login(a)
|
||||
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
return { token, client, refresh }
|
||||
}
|
||||
|
||||
async function authorizeCode(
|
||||
a: App,
|
||||
refresh: string,
|
||||
clientId: string,
|
||||
extra: Record<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('admin@test.local')
|
||||
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
|
||||
expect(noToken.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('без сессии authorize редиректит на логин', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client } = await setup(app)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers.location).toContain('return_to=')
|
||||
})
|
||||
|
||||
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
|
||||
app = await buildTestApp()
|
||||
const { refresh } = await setup(app)
|
||||
const secret = generateOidcClientSecret()
|
||||
const legacy = createOidcClient(app.db, {
|
||||
name: 'Legacy',
|
||||
redirectUris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile'],
|
||||
enabled: true,
|
||||
clientSecretHash: await hash(secret),
|
||||
})
|
||||
const code = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const res = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code,
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const row = app.sqlite
|
||||
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
|
||||
.get(legacy.clientId) as { h: string }
|
||||
expect(row.h.startsWith('sha256:')).toBe(true)
|
||||
|
||||
// после миграции секрет всё ещё работает
|
||||
const code2 = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const again = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code: code2,
|
||||
})
|
||||
expect(again.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
|
||||
app = await buildTestApp()
|
||||
const { token, client, refresh } = await setup(app)
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed RP' },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
|
||||
|
||||
const rotate = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(rotate.statusCode).toBe(200)
|
||||
const fresh = (rotate.json() as { client_secret: string }).client_secret
|
||||
expect(fresh).not.toBe(client.client_secret)
|
||||
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
|
||||
).toBe(401)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
const gone = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('app-switcher API', () => {
|
||||
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cdn',
|
||||
'cfdm',
|
||||
'dns',
|
||||
'fw',
|
||||
'mm',
|
||||
'vps',
|
||||
])
|
||||
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
|
||||
authMode: 'oidc',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
payload: { menuLabel: 'Apps', apps: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const getBefore = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const before = getBefore.json() as {
|
||||
menuLabel: string
|
||||
apps: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
const updated = {
|
||||
menuLabel: 'Сервисы',
|
||||
apps: before.apps.map((a) =>
|
||||
a.id === 'cfdm'
|
||||
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
|
||||
: a,
|
||||
),
|
||||
}
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: updated,
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
|
||||
|
||||
const getAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const after = getAfter.json() as typeof before
|
||||
expect(after.menuLabel).toBe('Сервисы')
|
||||
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
|
||||
'https://cfdm.example.test',
|
||||
)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('audit log API', () => {
|
||||
it('ingests external events with secret and dedupes by event_id', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
payload: { events: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const eventId = 'evt-test-1'
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
actor_email: 'ops@test.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
|
||||
|
||||
const dup = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?source_app=vps',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { source_app: string; action: string }[]
|
||||
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login and lists for admin', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string }[]
|
||||
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('updates retention and purges old rows', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
appendAudit(app.db, {
|
||||
action: 'user.create',
|
||||
summary: 'old event',
|
||||
})
|
||||
// Backdate the last inserted row
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
|
||||
)
|
||||
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
|
||||
|
||||
const settings = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/audit/settings',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { retention_days: 30 },
|
||||
})
|
||||
expect(settings.statusCode).toBe(200)
|
||||
expect(settings.json()).toEqual({ retention_days: 30 })
|
||||
|
||||
const before = listAudit(app.db, { limit: 500 })
|
||||
expect(before.some((e) => e.summary === 'old event')).toBe(true)
|
||||
|
||||
const deleted = purgeAuditOlderThan(app.db, 30)
|
||||
expect(deleted).toBeGreaterThanOrEqual(1)
|
||||
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
|
||||
false,
|
||||
)
|
||||
|
||||
const purge = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/audit/purge',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(purge.statusCode).toBe(200)
|
||||
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records user.create from admin mutation', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
email: 'new@test.local',
|
||||
name: 'New User',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?action=user.create',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string; summary: string }[]
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1)
|
||||
expect(entries[0]?.summary).toContain('new@test.local')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login with IP, UA and last_login_ip', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.10',
|
||||
'user-agent': 'VitestBrowser/1.0',
|
||||
},
|
||||
payload: {
|
||||
email: 'admin@test.local',
|
||||
password: 'adminpass',
|
||||
return_to: 'https://vps.example.test/dashboard',
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; last_login_ip: string | null }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.last_login_ip).toBe('203.0.113.10')
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
ip: string | null
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const loginEvt = entries.find((e) => e.action === 'auth.login')
|
||||
expect(loginEvt?.ip).toBe('203.0.113.10')
|
||||
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
|
||||
expect(loginEvt?.details?.target_app).toBe('vps')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records failed login with email and IP', async () => {
|
||||
const app = await buildTestApp()
|
||||
const fail = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrong' },
|
||||
})
|
||||
expect(fail.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
actor_email: string | null
|
||||
ip: string | null
|
||||
}[]
|
||||
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
|
||||
expect(entries[0]?.actor_email).toBe('nobody@test.local')
|
||||
expect(entries[0]?.ip).toBe('198.51.100.7')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records sso-access and separates kind=changes', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sso = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/sso-access',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { return_to: 'https://bgp.example.test/' },
|
||||
})
|
||||
expect(sso.statusCode).toBe(200)
|
||||
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
|
||||
|
||||
const logins = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const loginEntries = logins.json() as { action: string }[]
|
||||
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
|
||||
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
|
||||
|
||||
const changes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=changes',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const changeEntries = changes.json() as { action: string }[]
|
||||
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists and revokes refresh sessions', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sessions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(sessions.statusCode).toBe(200)
|
||||
const rows = sessions.json() as { id: string; user_id: string }[]
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||
const sessionId = rows[0]!.id
|
||||
|
||||
const revoke = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/sessions/${sessionId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(revoke.statusCode).toBe(200)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
|
||||
).toBe(false)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listBackups } from '@authportal/db'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { adminToken } from './helpers.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const d of dirs.splice(0)) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* Windows может держать файл БД до GC — временный каталог не критичен */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
|
||||
return loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
|
||||
NODE_ENV: 'test',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
describe('backup', () => {
|
||||
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
|
||||
const cfg = env('/data/x')
|
||||
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
|
||||
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
|
||||
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
|
||||
})
|
||||
|
||||
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_KEEP: '2' })
|
||||
const app = await buildApp({ config })
|
||||
const res = await runBackup(app.sqlite, config)
|
||||
expect(listBackups(backupDirFor(config))).toHaveLength(1)
|
||||
await app.close()
|
||||
|
||||
const restored = await buildApp({
|
||||
config: env(dir),
|
||||
databaseUrl: `sqlite:${res.file}`,
|
||||
})
|
||||
const token = await adminToken(restored as never)
|
||||
expect(token).toBeTruthy()
|
||||
await restored.close()
|
||||
})
|
||||
|
||||
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
|
||||
const app = await buildApp({ config })
|
||||
|
||||
const base = vi.getTimerCount()
|
||||
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
|
||||
expect(vi.getTimerCount()).toBe(base)
|
||||
off()
|
||||
|
||||
const stop = startBackupJob(app)
|
||||
expect(vi.getTimerCount()).toBe(base + 1)
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
|
||||
vi.useRealTimers()
|
||||
await vi.waitFor(() =>
|
||||
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
|
||||
)
|
||||
stop()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Writable } from 'node:stream'
|
||||
import Fastify from 'fastify'
|
||||
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
|
||||
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('значения по умолчанию для новых параметров', () => {
|
||||
const c = loadConfig({ ...base, NODE_ENV: 'test' })
|
||||
expect(c.shutdownTimeoutMs).toBe(8000)
|
||||
expect(c.sessionRetentionDays).toBe(7)
|
||||
expect(c.argon2Concurrency).toBe(2)
|
||||
expect(c.backupKeep).toBe(7)
|
||||
expect(c.backupIntervalHours).toBe(0)
|
||||
expect(c.corsOrigins).toBe('')
|
||||
expect(c.trustProxy).toContain('10.0.0.0/8')
|
||||
})
|
||||
|
||||
it('читает переопределения из env', () => {
|
||||
const c = loadConfig({
|
||||
...base,
|
||||
NODE_ENV: 'test',
|
||||
SHUTDOWN_TIMEOUT_MS: '1500',
|
||||
SESSION_RETENTION_DAYS: '30',
|
||||
ARGON2_CONCURRENCY: '4',
|
||||
BACKUP_KEEP: '3',
|
||||
BACKUP_INTERVAL_HOURS: '12',
|
||||
CORS_ORIGINS: 'https://a.example',
|
||||
TRUST_PROXY: 'false',
|
||||
})
|
||||
expect(c).toMatchObject({
|
||||
shutdownTimeoutMs: 1500,
|
||||
sessionRetentionDays: 30,
|
||||
argon2Concurrency: 4,
|
||||
backupKeep: 3,
|
||||
backupIntervalHours: 12,
|
||||
corsOrigins: 'https://a.example',
|
||||
trustProxy: 'false',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SHUTDOWN_TIMEOUT_MS', '0'],
|
||||
['ARGON2_CONCURRENCY', '-1'],
|
||||
['BACKUP_KEEP', 'abc'],
|
||||
['BACKUP_INTERVAL_HOURS', '-5'],
|
||||
])('невалидный %s=%s → ошибка', (key, value) => {
|
||||
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('production guard', () => {
|
||||
it.each(['change-me-in-production', 'dev-secret-change-me'])(
|
||||
'известный дефолт JWT_SECRET "%s" отклоняется',
|
||||
(secret) => {
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
|
||||
).toThrow(/JWT_SECRET/)
|
||||
},
|
||||
)
|
||||
|
||||
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
|
||||
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
|
||||
).not.toThrow()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
|
||||
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
|
||||
).not.toThrow()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
|
||||
).not.toThrow()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('пароль первого админа: слабый запрещён только в production', () => {
|
||||
const prod = (pw?: string) =>
|
||||
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
|
||||
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
|
||||
expect(() =>
|
||||
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('buildApp в production без нормального пароля падает при создании админа', async () => {
|
||||
await expect(
|
||||
buildApp({
|
||||
config: loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
...base,
|
||||
ADMIN_PASSWORD: 'admin',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
}),
|
||||
databaseUrl: 'sqlite::memory:',
|
||||
}),
|
||||
).rejects.toThrow(/ADMIN_PASSWORD/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redact в логах', () => {
|
||||
let app: ReturnType<typeof Fastify> | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('authorization и cookie не попадают в лог', async () => {
|
||||
const lines: string[] = []
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(String(chunk))
|
||||
cb()
|
||||
},
|
||||
})
|
||||
// Тот же набор путей, что и в buildApp.
|
||||
app = Fastify({
|
||||
logger: {
|
||||
level: 'info',
|
||||
stream,
|
||||
redact: {
|
||||
paths: ['req.headers.authorization', 'req.headers.cookie'],
|
||||
censor: '[redacted]',
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({ method: req.method, headers: req.headers }),
|
||||
},
|
||||
},
|
||||
})
|
||||
app.get('/x', async () => ({ ok: true }))
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/x',
|
||||
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
|
||||
})
|
||||
const joined = lines.join('')
|
||||
expect(joined).toContain('[redacted]')
|
||||
expect(joined).not.toContain('SECRET')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
import { startHousekeepingJob } from '../src/services/housekeeping.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('directory/users', () => {
|
||||
it('отдаёт только id/email/name и ограничивает 50', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
for (let i = 0; i < 55; i++) {
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||
VALUES (?,?,?,?,0,0,'t','t')`,
|
||||
)
|
||||
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
|
||||
}
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/directory/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as Array<Record<string, unknown>>
|
||||
expect(body).toHaveLength(50)
|
||||
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
|
||||
expect(res.body).not.toContain('hash')
|
||||
|
||||
const q = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/directory/users?q=U54',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((q.json() as unknown[]).length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingest audit', () => {
|
||||
const ev = (i: number) => ({
|
||||
event_id: `ev-${i}`,
|
||||
source_app: 'dns',
|
||||
action: 'dns.test',
|
||||
severity: 'info',
|
||||
summary: 'x',
|
||||
})
|
||||
|
||||
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
|
||||
app = await buildTestApp()
|
||||
const post = (events: unknown[]) =>
|
||||
app!.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: { events },
|
||||
})
|
||||
const first = await post([ev(1), ev(2), ev(3)])
|
||||
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
|
||||
const second = await post([ev(3), ev(4)])
|
||||
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('housekeeping job', () => {
|
||||
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
|
||||
vi.useFakeTimers()
|
||||
app = await buildTestApp()
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||
VALUES ('uh','h@t.io','H','x',0,0,'t','t')`,
|
||||
)
|
||||
.run()
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
|
||||
)
|
||||
.run()
|
||||
const stop = startHousekeepingJob(app)
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
const left = app.sqlite
|
||||
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
|
||||
.get()
|
||||
expect(left).toEqual({ c: 0 })
|
||||
stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
|
||||
|
||||
const dockerfile = read('deploy/docker/authportal/Dockerfile')
|
||||
const composes = {
|
||||
'docker-compose.yml': read('docker-compose.yml'),
|
||||
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
|
||||
}
|
||||
|
||||
describe('Dockerfile', () => {
|
||||
it('graceful stop и ограничение ресурсов Node', () => {
|
||||
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
|
||||
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
|
||||
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
|
||||
})
|
||||
|
||||
it('healthcheck без запуска node', () => {
|
||||
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
|
||||
expect(hc).toContain('wget')
|
||||
expect(hc).not.toContain('"node"')
|
||||
})
|
||||
|
||||
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
|
||||
expect(dockerfile).toMatch(/^USER node$/m)
|
||||
const runtime = dockerfile.split('AS runtime')[1]!
|
||||
expect(runtime).not.toMatch(/apk add/)
|
||||
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
|
||||
})
|
||||
|
||||
it('статика сжимается только brotli', () => {
|
||||
expect(dockerfile).toContain('brotli')
|
||||
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||
})
|
||||
|
||||
it('preview-маршруты дизайна вырезаются до сборки web', () => {
|
||||
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
|
||||
const build = dockerfile.indexOf('pnpm turbo build')
|
||||
expect(rm).toBeGreaterThan(0)
|
||||
expect(rm).toBeLessThan(build)
|
||||
})
|
||||
|
||||
it('базовый образ закреплён по digest', () => {
|
||||
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
|
||||
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||
})
|
||||
|
||||
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
|
||||
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
|
||||
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
|
||||
})
|
||||
|
||||
it('нативные модули проверяются при сборке', () => {
|
||||
expect(dockerfile).toContain('native modules ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||
it('init, grace period и лимиты ресурсов', () => {
|
||||
expect(text).toMatch(/init: true/)
|
||||
expect(text).toMatch(/stop_grace_period: 15s/)
|
||||
expect(text).toMatch(/mem_limit: 256m/)
|
||||
expect(text).toMatch(/pids_limit: \d+/)
|
||||
expect(text).toMatch(/read_only: true/)
|
||||
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
|
||||
expect(text).toMatch(/no-new-privileges:true/)
|
||||
})
|
||||
|
||||
it('healthcheck через wget', () => {
|
||||
expect(text).toMatch(/"wget", "-q"/)
|
||||
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traefik', () => {
|
||||
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
|
||||
const t = composes['deploy/docker-compose.traefik.yml']
|
||||
expect(t).toMatch(/condition: service_healthy/)
|
||||
expect(t).toContain('traefik", "healthcheck", "--ping')
|
||||
expect(t).toContain('--ping=true')
|
||||
})
|
||||
|
||||
it('включено сжатие ответов', () => {
|
||||
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||
'middlewares.auth-portal-compress.compress=true',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('error handler', () => {
|
||||
it('необработанная ошибка → 500 без деталей', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__boom', async () => {
|
||||
throw new Error('secret internals')
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
expect(res.body).not.toContain('secret internals')
|
||||
})
|
||||
|
||||
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__teapot', async () => {
|
||||
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||
expect(res.statusCode).toBe(418)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not found', () => {
|
||||
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||
'%s → 404 JSON',
|
||||
async (url) => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
},
|
||||
)
|
||||
|
||||
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.body).toContain('spa')
|
||||
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
|
||||
/** Фиксированный тестовый RSA-ключ: importPKCS8 (~мс) вместо generateKeyPair
|
||||
* (~100–300 мс) при построении приложения в каждом тесте. */
|
||||
const TEST_OIDC_RSA_PRIVATE_KEY = [
|
||||
'-----BEGIN PRIVATE KEY-----',
|
||||
'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCt/+Vwi6txs4ET',
|
||||
'/OczfcOX0mVCOfxT3qGejj1xX19A4gKeGGjQ5LSBDGzcztncYMd0J/H1ozUWn0NU',
|
||||
'ctolNRSTFchAYj1MoC2Pacfhe445nLR4bOssdADO9bwhHmWy8WQFyYc/lqs/xPSd',
|
||||
'haZFwDRBRRfh9uxzzUPR8FXCovHEJG5HiLuvmALL1W7nkDg+L+vyLYCNje482hS+',
|
||||
'ZcW3IH17gQpXgpqgXPaGHfZT1teIiYwhelaqPsApMQroOisKYGoQi1z/G/mdvaOF',
|
||||
'/jravyBqhr5Hm03lFOMBvMptGrXsjopy+BVlqBVcvdwdyFfz94dayhnCmfEIvK7W',
|
||||
'ynYGtoLnAgMBAAECggEAKqld04DjOb8FzGVLOKRCwyQ6YIkAFX42CT6d0QDNxYZE',
|
||||
'lwesF1+j5CosPN1YrEBsVs3+dLgd6ZyF8Rwx4UKI92E1VpffqLxzP0Dz2ZSmM/j4',
|
||||
'hLhL1vvgQN4eUM53p+6olASr7QrflQSQ51hgAqUUCtbKesREiEYRNVWkaVLzTSAr',
|
||||
'KrwWAF81hzEms0KuHw6/EgThBkWYGAMzZjECFa173yBHklt1ikdB1AOmieL9PZbB',
|
||||
'anjIequkUE2yoTJM/wOd2GN7sy61PmnMApwl1ke6k4VhrJeQhfahck0D2ynDvWqX',
|
||||
'fnn1Wr/BRmSi38RNEOySWkrCVjM0XSjO3wEuHFFyuQKBgQDwgOgzgL1W5b13LKXo',
|
||||
'8F/AJNRmi4vpM1lftMRalx5NU5ugw1iQ2zJzLG3Tl1/nvyt8tGFkXB7Dw9M9H0as',
|
||||
'jjk7D8ZzL1a7COWQx7wya1l8vMxwv40ERvoWypwpuVo12SvRnHWgGwsbqW4wxYIG',
|
||||
'9HsdvbxglFiVcSq+b6FDdg2nyQKBgQC5NgKatFPPed/DFb6MCkW+5Wnw2HzdKHxD',
|
||||
'fEkAJeb3xX+9k7OifRHppurwaDV4jUPJ5W1Whly77cg+xmEYAbUdPS1kgfYJaDn1',
|
||||
'mPN9dI7RP15v4X46/+5v5EZYAKcdUdMzr9gJ2iQVpoDi5ojp+LJknPLBuo7XTfp3',
|
||||
'kj9lifONLwKBgHa3SudRXyTr1rBjC6qGLN2N0IKl03RTcoR5mIsXGVAWOzYJ90fZ',
|
||||
'y3dqsPPHPwIILZErAQAnMNB1c2oybBJ1Se7LhKwK7Ab7B0aGMXG557xY/ZmSFCvO',
|
||||
'VVi2vosiAVOfVa/sSWu0rbED226br+l6+OLfa4NI9skdyXmjZLkGJwtxAoGBALDp',
|
||||
'LkOnzAZ6W4p00rXheg2GBVPQ9jQp38h2hyRlD+HL/OCQnR2wadPNZMyOPcUQ6peH',
|
||||
'VpE3dozr7EJKK6RKvT1YmmtO2DzUGcjGeI51L5Yf+YzUz0Z8AL1QRwD4Xqha6GqO',
|
||||
'pMWEYMNtb99MZatXqs8AO8Fg+eAe1Agy4euTgdhxAoGBAPAgrLUl7uJMdCsrHFEG',
|
||||
'K8CH3BZwz2ytSj6429IdX4OCzkkmdsWnjx5/FK2hc+fXMluhybaIziSntgs55vzZ',
|
||||
'1hd/Xw1T2FSTS6BuWegxVtjO6BQjbVXrxOumA0PPmFYaGMPvvBQrbZy8OSeuaKD/',
|
||||
'a9jKZApDKnT8vmYvmyMMtsmE',
|
||||
'-----END PRIVATE KEY-----',
|
||||
].join('\n')
|
||||
|
||||
export async function buildTestApp(env: NodeJS.ProcessEnv = {}) {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
|
||||
OIDC_RSA_PRIVATE_KEY: TEST_OIDC_RSA_PRIVATE_KEY,
|
||||
...env,
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
export async function adminToken(
|
||||
app: Awaited<ReturnType<typeof buildTestApp>>,
|
||||
): Promise<string> {
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hash } from '@node-rs/argon2'
|
||||
import { TtlCache } from '../src/lib/cache.js'
|
||||
import { parseCookie } from '../src/lib/cookies.js'
|
||||
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
|
||||
import {
|
||||
hashPassword,
|
||||
passwordHashStats,
|
||||
setPasswordConcurrency,
|
||||
verifyPassword,
|
||||
} from '../src/lib/password.js'
|
||||
import { parseTrustProxy } from '../src/config.js'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('TtlCache', () => {
|
||||
it('истекает по TTL', () => {
|
||||
let t = 0
|
||||
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
|
||||
c.set('a', 1)
|
||||
t = 99
|
||||
expect(c.get('a')).toBe(1)
|
||||
t = 100
|
||||
expect(c.get('a')).toBeUndefined()
|
||||
expect(c.size).toBe(0)
|
||||
})
|
||||
|
||||
it('вытесняет давно не использованное (LRU)', () => {
|
||||
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
|
||||
c.set('a', 1)
|
||||
c.set('b', 2)
|
||||
c.get('a') // освежили a
|
||||
c.set('c', 3)
|
||||
expect(c.get('b')).toBeUndefined()
|
||||
expect(c.get('a')).toBe(1)
|
||||
expect(c.get('c')).toBe(3)
|
||||
c.delete('a')
|
||||
expect(c.get('a')).toBeUndefined()
|
||||
c.clear()
|
||||
expect(c.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseCookie', () => {
|
||||
it('находит значение и декодирует', () => {
|
||||
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
|
||||
'abc d',
|
||||
)
|
||||
})
|
||||
it('не путает суффикс имени', () => {
|
||||
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
|
||||
})
|
||||
it('пусто/нет → null', () => {
|
||||
expect(parseCookie(undefined, 'a')).toBeNull()
|
||||
expect(parseCookie('a=; b=1', 'a')).toBeNull()
|
||||
expect(parseCookie('b=1', 'a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client secret', () => {
|
||||
it('sha256-формат: верный/неверный, без rehash', async () => {
|
||||
const stored = hashClientSecret('s3cret')
|
||||
expect(stored.startsWith('sha256:')).toBe(true)
|
||||
expect(await checkClientSecret(stored, 's3cret')).toEqual({
|
||||
ok: true,
|
||||
needsRehash: false,
|
||||
})
|
||||
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('argon2-формат принимается и помечается на rehash', async () => {
|
||||
const legacy = await hash('old-secret')
|
||||
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
|
||||
ok: true,
|
||||
needsRehash: true,
|
||||
})
|
||||
expect(await checkClientSecret(legacy, 'bad')).toEqual({
|
||||
ok: false,
|
||||
needsRehash: false,
|
||||
})
|
||||
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password limiter', () => {
|
||||
it('не допускает больше N одновременных хешей', async () => {
|
||||
setPasswordConcurrency(2)
|
||||
let maxActive = 0
|
||||
const poll = setInterval(() => {
|
||||
maxActive = Math.max(maxActive, passwordHashStats().active)
|
||||
}, 1)
|
||||
const h = await hashPassword('pw')
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
|
||||
)
|
||||
clearInterval(poll)
|
||||
expect(maxActive).toBeLessThanOrEqual(2)
|
||||
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
|
||||
setPasswordConcurrency(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTrustProxy', () => {
|
||||
it('true/false/список', () => {
|
||||
expect(parseTrustProxy('true')).toBe(true)
|
||||
expect(parseTrustProxy('false')).toBe(false)
|
||||
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
|
||||
})
|
||||
})
|
||||
|
||||
async function createUserAndLogin(
|
||||
a: NonNullable<typeof app>,
|
||||
adminTok: string,
|
||||
email: string,
|
||||
) {
|
||||
const create = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${adminTok}` },
|
||||
payload: {
|
||||
email,
|
||||
name: 'U',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
const id = (create.json() as { id: string }).id
|
||||
const login = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email, password: 'secret12' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return { id, token: (login.json() as { access_token: string }).access_token }
|
||||
}
|
||||
|
||||
describe('кэш профиля пользователя', () => {
|
||||
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { token } = await createUserAndLogin(app, admin, 'cache@test.local')
|
||||
const me = () =>
|
||||
app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((await me()).statusCode).toBe(200) // прогрев
|
||||
|
||||
const spy = vi.spyOn(app.sqlite, 'prepare')
|
||||
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
|
||||
const sqls = spy.mock.calls.map((c) => String(c[0]))
|
||||
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
|
||||
.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('блокировка пользователя действует немедленно', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { id, token } = await createUserAndLogin(app, admin, 'ban@test.local')
|
||||
const me = () =>
|
||||
app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((await me()).statusCode).toBe(200)
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
payload: { disabled: true },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((await me()).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('смена доступа отражается сразу, удаление → 401', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const { id, token } = await createUserAndLogin(app, admin, 'acc@test.local')
|
||||
const me = async () =>
|
||||
(
|
||||
await app!.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
).json() as { apps?: string[] }
|
||||
expect((await me()).apps).toEqual(['cfdm'])
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/v1/admin/users/${id}/access`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
payload: { apps: ['dns'], permissions: [] },
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect((await me()).apps).toEqual(['dns'])
|
||||
|
||||
await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
})
|
||||
const gone = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(gone.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('app-switcher кэш', () => {
|
||||
it('PUT сразу виден в GET', async () => {
|
||||
app = await buildTestApp()
|
||||
const admin = await adminToken(app)
|
||||
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
const cfg = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${admin}` },
|
||||
})
|
||||
expect(before.statusCode).toBe(200)
|
||||
expect(cfg.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rate-limit', () => {
|
||||
it('/health не лимитируется', async () => {
|
||||
app = await buildTestApp()
|
||||
for (let i = 0; i < 250; i++) {
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
it('логин ограничен 20/мин', async () => {
|
||||
app = await buildTestApp()
|
||||
let last = 0
|
||||
for (let i = 0; i < 22; i++) {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'nobody@test.local', password: 'wrongpass' },
|
||||
})
|
||||
last = res.statusCode
|
||||
}
|
||||
expect(last).toBe(429)
|
||||
})
|
||||
|
||||
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
|
||||
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
remoteAddress: '203.0.113.9',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrongpass' },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
const row = app.sqlite
|
||||
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
|
||||
.get() as { ip: string }
|
||||
expect(row.ip).toBe('203.0.113.9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createOriginChecker } from '../src/lib/cors.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
function staticDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
mkdirSync(join(dir, 'assets'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('статика', () => {
|
||||
it('hashed-ассеты immutable, index.html no-cache', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
|
||||
expect(asset.statusCode).toBe(200)
|
||||
expect(asset.headers['cache-control']).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
)
|
||||
const index = await app.inject({ method: 'GET', url: '/index.html' })
|
||||
expect(index.headers['cache-control']).toBe('no-cache')
|
||||
const spa = await app.inject({ method: 'GET', url: '/users/42' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.headers['cache-control']).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.body).not.toContain('<html>')
|
||||
})
|
||||
|
||||
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
|
||||
const dir = staticDir()
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/app-abc123.js',
|
||||
headers: { 'accept-encoding': 'br' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBe('br')
|
||||
expect(res.body).toBe('BR-BYTES')
|
||||
})
|
||||
})
|
||||
|
||||
describe('кэш-заголовки API', () => {
|
||||
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
|
||||
app = await buildTestApp()
|
||||
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(sw.headers['cache-control']).toBeUndefined()
|
||||
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('health / ready', () => {
|
||||
it('/health не трогает БД и живёт после закрытия БД', async () => {
|
||||
app = await buildTestApp()
|
||||
app.sqlite.close()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('/ready 200 в норме и 503 при недоступной БД', async () => {
|
||||
app = await buildTestApp()
|
||||
const ok = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(ok.statusCode).toBe(200)
|
||||
app.sqlite.close()
|
||||
const bad = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(bad.statusCode).toBe(503)
|
||||
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CORS', () => {
|
||||
const origin = (url: string) => ({ origin: url })
|
||||
|
||||
it('разрешает issuer/allowlist, блокирует чужие', async () => {
|
||||
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
|
||||
const allowed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://auth.test.local'),
|
||||
})
|
||||
expect(allowed.headers['access-control-allow-origin']).toBe(
|
||||
'https://auth.test.local',
|
||||
)
|
||||
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://evil.example'),
|
||||
})
|
||||
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
|
||||
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://extra.example'),
|
||||
})
|
||||
expect(res.headers['access-control-allow-origin']).toBe(
|
||||
'https://extra.example',
|
||||
)
|
||||
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(none.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
|
||||
const list =
|
||||
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.shnt.top',
|
||||
RETURN_TO_ALLOWLIST: list,
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
for (const o of [
|
||||
'https://vps.shnt.top',
|
||||
'https://dns.shnt.top',
|
||||
'https://shnt.top',
|
||||
'https://anything.shnt.top',
|
||||
]) {
|
||||
expect(check(o)).toBe(true)
|
||||
}
|
||||
expect(check('https://evil.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('private в allowlist пускает LAN-хосты', () => {
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,private',
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
expect(check('http://192.168.1.10:8080')).toBe(true)
|
||||
expect(check('http://10.0.0.5')).toBe(true)
|
||||
expect(check('https://8.8.8.8')).toBe(false)
|
||||
})
|
||||
|
||||
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
|
||||
const base = {
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
|
||||
}
|
||||
const check = createOriginChecker(loadConfig(base), () => [
|
||||
'https://dns.custom.io/path',
|
||||
])
|
||||
expect(check('https://cfdm.shnt.top')).toBe(true)
|
||||
expect(check('http://localhost:5173')).toBe(true)
|
||||
expect(check('https://dns.custom.io')).toBe(true)
|
||||
expect(check('https://evilshnt.top')).toBe(false)
|
||||
expect(check('not a url')).toBe(false)
|
||||
const all = createOriginChecker(
|
||||
loadConfig({ ...base, CORS_ORIGINS: '*' }),
|
||||
() => [],
|
||||
)
|
||||
expect(all('https://anything.example')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
|
||||
async function withBigRoute(env: NodeJS.ProcessEnv) {
|
||||
app = await buildTestApp(env)
|
||||
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
|
||||
app.get('/__small', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
it('по умолчанию выключено', async () => {
|
||||
const a = await withBigRoute({})
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
|
||||
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
|
||||
const big = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(big.headers['content-encoding']).toBe('gzip')
|
||||
expect(big.rawPayload.length).toBeLessThan(500)
|
||||
const small = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__small',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(small.headers['content-encoding']).toBeUndefined()
|
||||
const none = await a.inject({ method: 'GET', url: '/__big' })
|
||||
expect(none.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
});
|
||||
|
||||
describe('CSP', () => {
|
||||
it('отдаётся жёсткая политика без внешних источников', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
const csp = String(res.headers['content-security-policy'])
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain("script-src 'self'")
|
||||
expect(csp).toContain("frame-ancestors 'none'")
|
||||
expect(csp).toContain("object-src 'none'")
|
||||
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
|
||||
expect(csp).not.toContain('http:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { jwtVerify, createLocalJWKSet } from 'jose'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('OIDC IdP', () => {
|
||||
it('serves discovery and JWKS', async () => {
|
||||
const app = await buildTestApp()
|
||||
const discovery = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/openid-configuration',
|
||||
})
|
||||
expect(discovery.statusCode).toBe(200)
|
||||
const meta = discovery.json() as {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
jwks_uri: string
|
||||
}
|
||||
expect(meta.issuer).toBe('https://auth.test.local')
|
||||
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
|
||||
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
|
||||
|
||||
const jwks = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks.statusCode).toBe(200)
|
||||
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
|
||||
expect(keys.keys.length).toBeGreaterThan(0)
|
||||
expect(keys.keys[0]?.kty).toBe('RSA')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records auth.sso_handoff with target_app dns on authorize', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as { client_id: string }
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
},
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
|
||||
const audit = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&limit=50',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(audit.statusCode).toBe(200)
|
||||
const entries = audit.json() as {
|
||||
action: string
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const handoff = entries.find(
|
||||
(e) =>
|
||||
e.action === 'auth.sso_handoff' &&
|
||||
e.details?.target_app === 'dns' &&
|
||||
e.details?.auth_mode === 'oidc',
|
||||
)
|
||||
expect(handoff).toBeTruthy()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
||||
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
||||
try {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: dbPath,
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
const first = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks1 = await first.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks1.statusCode).toBe(200)
|
||||
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
|
||||
expect(kid).toBeTruthy()
|
||||
await first.close()
|
||||
|
||||
resetOidcKeyCache()
|
||||
const second = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks2 = await second.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks2.statusCode).toBe(200)
|
||||
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
|
||||
kid,
|
||||
)
|
||||
await second.close()
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Windows may keep better-sqlite3 handle briefly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('authorization code flow issues id_token with groups', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
nonce: 'n1',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
const location = authorize.headers.location!
|
||||
expect(location).toContain('https://dns.test.local/sso/callback')
|
||||
const code = new URL(location).searchParams.get('code')
|
||||
expect(code).toBeTruthy()
|
||||
|
||||
const tokenRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code: code!,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
},
|
||||
})
|
||||
expect(tokenRes.statusCode).toBe(200)
|
||||
const tokens = tokenRes.json() as {
|
||||
access_token: string
|
||||
id_token: string
|
||||
token_type: string
|
||||
}
|
||||
expect(tokens.token_type).toBe('Bearer')
|
||||
|
||||
const jwksRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
|
||||
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
||||
issuer: 'https://auth.test.local',
|
||||
audience: client.client_id,
|
||||
})
|
||||
expect(payload.sub).toBeTruthy()
|
||||
expect(payload.email).toBe('admin@test.local')
|
||||
expect(payload.nonce).toBe('n1')
|
||||
const groups = payload.groups as string[]
|
||||
expect(groups).toContain('technitium_admins')
|
||||
expect(groups).toContain('technitium_dns_admins')
|
||||
|
||||
const userinfo = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/userinfo',
|
||||
headers: { authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
expect(userinfo.statusCode).toBe(200)
|
||||
const info = userinfo.json() as { groups: string[]; email: string }
|
||||
expect(info.email).toBe('admin@test.local')
|
||||
expect(info.groups).toContain('technitium_admins')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects invalid client secret and redirect_uri mismatch', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'DNS',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const badRedirect = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://evil.test/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
expect(badRedirect.statusCode).toBe(400)
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
const code = new URL(authorize.headers.location!).searchParams.get('code')!
|
||||
|
||||
const badSecret = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: 'wrong-secret',
|
||||
},
|
||||
})
|
||||
expect(badSecret.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
function fakeApp(close: () => Promise<void>) {
|
||||
return {
|
||||
close,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||
}
|
||||
|
||||
describe('registerShutdown', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||
let release!: () => void
|
||||
const close = vi.fn(
|
||||
() => new Promise<void>((r) => (release = r)),
|
||||
)
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
proc.emit('SIGINT')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
})
|
||||
|
||||
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(() => new Promise<void>(() => {})),
|
||||
{ timeoutMs: 500, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('ошибка close → выход с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('unhandledRejection', new Error('x'))
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('dispose снимает обработчики', () => {
|
||||
const proc = new EventEmitter()
|
||||
const h = registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit: vi.fn(),
|
||||
proc: proc as never,
|
||||
})
|
||||
h.dispose()
|
||||
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeDb', () => {
|
||||
it('идемпотентно закрывает БД', () => {
|
||||
const { sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||
})
|
||||
|
||||
it('app.close() закрывает БД приложения', async () => {
|
||||
const app = await buildTestApp()
|
||||
await app.ready()
|
||||
expect(app.sqlite.open).toBe(true)
|
||||
await app.close()
|
||||
expect(app.sqlite.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { targetAppFromReturnTo } from '../src/lib/target-app.js'
|
||||
|
||||
describe('targetAppFromReturnTo', () => {
|
||||
it('maps dns host and /sso/ path', () => {
|
||||
expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe(
|
||||
'dns',
|
||||
)
|
||||
})
|
||||
|
||||
it('unwraps portal /oauth/authorize return_to via redirect_uri', () => {
|
||||
const returnTo =
|
||||
'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' +
|
||||
encodeURIComponent('https://dns.shnt.top/sso/callback') +
|
||||
'&response_type=code&scope=openid'
|
||||
expect(targetAppFromReturnTo(returnTo)).toBe('dns')
|
||||
})
|
||||
|
||||
it('keeps portal for bare issuer authorize without redirect_uri', () => {
|
||||
expect(
|
||||
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
|
||||
).toBe('portal')
|
||||
})
|
||||
|
||||
it('maps cdn host', () => {
|
||||
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
|
||||
'cdn',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps mm host', () => {
|
||||
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
|
||||
'mm',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createWebauthnCredential } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('webauthn / passkeys', () => {
|
||||
it('exposes webauthn flag on auth config', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ webauthn: true })
|
||||
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires JWT for register options', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
const body = ok.json() as {
|
||||
challenge_id: string
|
||||
options: { challenge: string; rp: { id: string } }
|
||||
}
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
expect(body.options.challenge).toBeTruthy()
|
||||
expect(body.options.rp.id).toBe('auth.test.local')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows public login options and rejects a bogus assertion', async () => {
|
||||
const app = await buildTestApp()
|
||||
const options = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login/options',
|
||||
})
|
||||
expect(options.statusCode).toBe(200)
|
||||
const body = options.json() as { challenge_id: string; options: unknown }
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login',
|
||||
payload: {
|
||||
challenge_id: body.challenge_id,
|
||||
response: { id: 'not-a-credential', type: 'public-key' },
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists, deletes own credentials and reports passkey_count', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'dGVzdC1jcmVkLWlk',
|
||||
publicKey: 'dGVzdC1wdWJrZXk',
|
||||
counter: 0,
|
||||
name: 'Test key',
|
||||
})
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.statusCode).toBe(200)
|
||||
const creds = listed.json() as { id: string; name: string }[]
|
||||
expect(creds).toHaveLength(1)
|
||||
expect(creds[0]?.name).toBe('Test key')
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; passkey_count: number }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.passkey_count).toBe(1)
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { name: 'Laptop' },
|
||||
})
|
||||
expect(renamed.statusCode).toBe(200)
|
||||
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
||||
|
||||
const deleted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(deleted.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(empty.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lets admin reset a user passkeys', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'cmVzZXQta2V5',
|
||||
publicKey: 'cHVia2V5',
|
||||
counter: 1,
|
||||
name: 'To reset',
|
||||
})
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${userId}/passkeys`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user