Фиксированный ключ (OIDC_RSA_PRIVATE_KEY) вместо generateKeyPair на каждое построение приложения: фаза тестов 9.9с → 1.6с (×6). Локальные копии buildTestApp/adminToken в 4 файлах заменены на helpers.ts
161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
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 === '[email protected]')
|
|
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()
|
|
})
|
|
})
|