fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = '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)
})
})
+95
View File
@@ -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()
})
})
+302
View File
@@ -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()
})
})
+82
View File
@@ -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()
})
})
+150
View File
@@ -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')
})
})
+95
View File
@@ -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)
})
})
+91
View File
@@ -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',
)
})
})
+77
View File
@@ -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')
}
})
})
+67
View File
@@ -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
}
+277
View File
@@ -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')
})
})
+232
View File
@@ -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:')
})
})
+311
View File
@@ -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()
})
})
+120
View File
@@ -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)
})
})
+36
View File
@@ -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',
)
})
})
+160
View File
@@ -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()
})
})