import argon2 from 'argon2'; import { afterEach, describe, expect, it } from 'vitest'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { truncateTables } from './truncate.js'; const email = 'admin@example.com'; const password = 'correct-password'; /** * MySQL may return a JSON column as a parsed value or as text. * @param {unknown} value */ function parseJson(value) { if (typeof value !== 'string') { return value; } try { return JSON.parse(value); } catch { return value; } } /** * @param {import('@hapi/hapi').ServerInjectResponse} response */ function sessionCookie(response) { const raw = response.headers['set-cookie']; const header = Array.isArray(raw) ? raw[0] : raw; return header.split(';')[0]; } describe('admin auth', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; afterEach(async () => { await server?.stop(); await truncateTables(); }); async function start() { server = await createServer(); return server; } async function createAdmin(overrides = {}) { await db('admin_users').insert({ name: 'Administrator', email, password_hash: await argon2.hash(password, { type: argon2.argon2id }), is_active: true, ...overrides, }); } it('seeds signatory settings', async () => { const rows = await db('settings').select('key', 'value'); const settings = Object.fromEntries(rows.map((row) => [row.key, parseJson(row.value)])); expect(settings.signatory_name).toBe('Dr Sudeep Salvi'); expect(settings.signatory_designation).toBe(''); expect(settings.signatory_signature_key).toBe(''); expect(settings.mail_from_name).toBe(''); }); it('logs in, returns the admin, and logs out', async () => { await createAdmin(); const api = await start(); const login = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); expect(login.statusCode).toBe(200); expect(login.result).toMatchObject({ name: 'Administrator', email }); const cookie = sessionCookie(login); expect(cookie.startsWith('admin_token=')).toBe(true); expect(login.headers['set-cookie'][0]).toMatch(/HttpOnly/i); expect(login.headers['set-cookie'][0]).toMatch(/SameSite=Lax/i); const me = await api.inject({ method: 'GET', url: '/api/admin/auth/me', headers: { cookie }, }); expect(me.statusCode).toBe(200); expect(me.result).toMatchObject({ email }); const logout = await api.inject({ method: 'POST', url: '/api/admin/auth/logout', headers: { cookie }, }); expect(logout.statusCode).toBe(200); const audits = await db('audit_log').select('action').orderBy('id'); expect(audits.map((row) => row.action)).toEqual(['ADMIN_LOGIN', 'ADMIN_LOGOUT']); }); it('rejects a missing session', async () => { const api = await start(); const response = await api.inject({ method: 'GET', url: '/api/admin/auth/me' }); expect(response.statusCode).toBe(401); expect(response.result).toEqual({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' }, }); }); it('uses the same error for an unknown email and a wrong password', async () => { await createAdmin(); const api = await start(); const unknown = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email: 'missing@example.com', password }, }); const wrong = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password: 'wrong-password' }, }); expect(unknown.statusCode).toBe(401); expect(wrong.statusCode).toBe(401); expect(unknown.result).toEqual(wrong.result); }); it('locks the account on the fifth failure', async () => { await createAdmin(); const api = await start(); for (let attempt = 0; attempt < 5; attempt += 1) { const response = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password: 'wrong-password' }, }); expect(response.statusCode).toBe(401); } const locked = await db('admin_users').where({ email }).first(); expect(locked.failed_login_count).toBe(0); expect(new Date(locked.locked_until).getTime()).toBeGreaterThan(Date.now()); const duringLock = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); expect(duringLock.statusCode).toBe(401); await db('admin_users') .where({ email }) .update({ locked_until: new Date(Date.now() - 1000) }); const afterLock = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); expect(afterLock.statusCode).toBe(200); const cleared = await db('admin_users').where({ email }).first(); expect(cleared.failed_login_count).toBe(0); expect(cleared.locked_until).toBeNull(); }); it('rejects an inactive admin', async () => { await createAdmin({ is_active: false }); const api = await start(); const response = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); expect(response.statusCode).toBe(401); }); });