import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it } from 'vitest'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { clearOutbox, outbox } from '../src/services/mail.js'; import { truncateTables } from './truncate.js'; const email = 'doctor@example.com'; /** * @param {import('@hapi/hapi').ServerInjectResponse} response * @param {string} name */ function cookieNamed(response, name) { const raw = response.headers['set-cookie']; const headers = Array.isArray(raw) ? raw : raw ? [raw] : []; const match = headers.find((header) => header.startsWith(`${name}=`)); return match ? match.split(';')[0] : null; } describe('user OTP auth', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; afterEach(async () => { clearOutbox(); await server?.stop(); await truncateTables(); }); async function start() { await db('pppaimt_doctors').insert({ email, hcp_name: 'Dr Test' }); server = await createServer(); return server; } it('requests an OTP, verifies it, returns /api/me, and logs out', async () => { const api = await start(); const requested = await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email }, }); expect(requested.statusCode).toBe(200); expect(requested.result).toMatchObject({ ok: true }); const user = await db('users').where({ email }).first(); expect(user).toBeTruthy(); const application = await db('applications').where({ user_id: user.id }).first(); expect(application).toMatchObject({ email, status: 'DRAFT' }); expect(outbox).toHaveLength(1); expect(outbox[0]?.template).toBe('login_otp'); expect(outbox[0]?.otp).toMatch(/^\d{6}$/); const verified = await api.inject({ method: 'POST', url: '/api/auth/otp/verify', payload: { email, otp: outbox[0].otp }, }); expect(verified.statusCode).toBe(200); expect(verified.result).toMatchObject({ user: { email }, me: { nextStep: 'consent' }, }); const session = cookieNamed(verified, 'user_token'); expect(session).toBeTruthy(); const me = await api.inject({ method: 'GET', url: '/api/me', headers: { cookie: session }, }); expect(me.statusCode).toBe(200); expect(me.result).toMatchObject({ user: { email }, steps: { consent: { done: false }, agreement: { done: false }, survey: { done: false }, }, nextStep: 'consent', }); const logout = await api.inject({ method: 'POST', url: '/api/auth/logout', headers: { cookie: session }, }); expect(logout.statusCode).toBe(200); expect(cookieNamed(logout, 'user_token')).toMatch(/user_token=/); expect(String(logout.headers['set-cookie'])).toMatch(/Max-Age=0|Expires=/i); const missing = await api.inject({ method: 'GET', url: '/api/me' }); expect(missing.statusCode).toBe(401); expect(await db('audit_log').where({ action: 'USER_LOGOUT' }).first()).toBeTruthy(); }); it('clears the cookie even without a valid session', async () => { const api = await start(); const logout = await api.inject({ method: 'POST', url: '/api/auth/logout' }); expect(logout.statusCode).toBe(200); expect(logout.result).toEqual({ ok: true }); expect(String(logout.headers['set-cookie'])).toMatch(/user_token=.*(Max-Age=0|Expires=)/i); expect(await db('audit_log').where({ action: 'USER_LOGOUT' }).first()).toBeUndefined(); }); it('rejects an invalid OTP', async () => { const api = await start(); await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email }, }); const verified = await api.inject({ method: 'POST', url: '/api/auth/otp/verify', payload: { email, otp: '000000' }, }); expect(verified.statusCode).toBe(401); }); it('refuses an email that is not in pppaimt_doctors without creating anything', async () => { const api = await start(); const stranger = 'not-a-doctor@example.com'; const requested = await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: stranger }, }); expect(requested.statusCode).toBe(403); expect(requested.result.error.message).toBe('Invalid User!'); expect(await db('users').where({ email: stranger }).first()).toBeUndefined(); expect(await db('applications').where({ email: stranger }).first()).toBeUndefined(); expect(await db('login_otps').where({ email: stranger }).first()).toBeUndefined(); expect(outbox).toHaveLength(0); const verified = await api.inject({ method: 'POST', url: '/api/auth/otp/verify', payload: { email: stranger, otp: '123456' }, }); expect(verified.statusCode).toBe(403); }); it('fills the user and draft application name from HCP NAME without a leading Dr', async () => { const api = await start(); const doctorEmail = 'tilve@example.com'; await db('pppaimt_doctors').insert({ email: doctorEmail, hcp_name: 'Dr. Sandeep S Tilve' }); await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: doctorEmail } }); const user = await db('users').where({ email: doctorEmail }).first(); expect(user.name).toBe('Sandeep S Tilve'); const application = await db('applications').where({ user_id: user.id }).first(); expect(application.full_name).toBe('Sandeep S Tilve'); expect(application.account_holder).toBeNull(); }); it('keeps names the doctor already entered', async () => { const api = await start(); const [userId] = await db('users').insert({ email, name: 'Typed Name', is_active: true }); await db('applications').insert({ public_id: randomUUID(), email, status: 'DRAFT', user_id: userId, full_name: 'Typed Full Name', }); await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email } }); expect((await db('users').where({ id: userId }).first()).name).toBe('Typed Name'); expect((await db('applications').where({ user_id: userId }).first()).full_name).toBe( 'Typed Full Name', ); }); it('does not strip names that only start with the letters Dr', async () => { const api = await start(); const doctorEmail = 'drew@example.com'; await db('pppaimt_doctors').insert({ email: doctorEmail, hcp_name: 'Drew Mathews' }); await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: doctorEmail } }); expect((await db('users').where({ email: doctorEmail }).first()).name).toBe('Drew Mathews'); }); it('matches the doctor email regardless of case', async () => { const api = await start(); const requested = await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: 'Doctor@Example.COM' }, }); expect(requested.statusCode).toBe(200); expect(outbox).toHaveLength(1); }); });