import { describe, expect, it } from 'vitest' import { buildApp } from '../src/app.js' import { loadConfig } from '../src/config.js' import { createWebauthnCredential } from '@authportal/db' async function buildTestApp() { 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', NODE_ENV: 'test', }) return buildApp({ config, databaseUrl: 'sqlite::memory:' }) } async function adminToken(app: Awaited>) { 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 } 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() }) })