import { randomBytes, randomUUID } from 'node:crypto'; import argon2 from 'argon2'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createServer } from '../src/app.js'; import { db } from '../src/db/knex.js'; import { clearOutbox, outbox } from '../src/services/mail.js'; import { putObject } from '../src/services/storage.js'; import { truncateTables } from './truncate.js'; vi.mock('../src/pdf/render.js', () => ({ renderPdf: vi.fn(async () => Buffer.from('%PDF-1.4')), })); const email = 'admin@example.com'; const password = 'correct-password'; const png = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); /** * @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 applications', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; beforeEach(async () => { await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('') }); }); afterEach(async () => { await server?.stop(); clearOutbox(); await truncateTables(); await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('') }); }); async function start() { server = await createServer(); return server; } async function login(api) { await db('admin_users').insert({ name: 'Administrator', email, password_hash: await argon2.hash(password, { type: argon2.argon2id }), is_active: true, }); const response = await api.inject({ method: 'POST', url: '/api/admin/auth/login', payload: { email, password }, }); return { cookie: sessionCookie(response), admin: response.result }; } async function insertApplication(status) { const [id] = await db('applications').insert({ public_id: randomUUID(), email: 'physician@example.com', status, full_name: 'Asha Rao', agreement_date: '2026-09-30', submitted_at: status === 'DRAFT' ? null : new Date(), version: 1, }); await db('resume_tokens').insert({ application_id: id, token_hash: randomBytes(32).toString('hex'), created_at: new Date(), }); await db('signatures').insert({ application_id: id, version: 1, type: 'TYPED', typed_text: 'Asha Rao', signed_at: new Date(), }); return id; } it('rejects approval unless the application is submitted and the signature is configured', async () => { const api = await start(); const { cookie } = await login(api); const draftId = await insertApplication('DRAFT'); const headers = { cookie }; const draft = await api.inject({ method: 'POST', url: `/api/admin/applications/${draftId}/approve`, headers, }); expect(draft.statusCode).toBe(409); const submittedId = await insertApplication('SUBMITTED'); const missing = await api.inject({ method: 'POST', url: `/api/admin/applications/${submittedId}/approve`, headers, }); expect(missing.statusCode).toBe(400); expect(missing.result).toEqual({ error: { code: 'BAD_REQUEST', message: 'Upload the CREST signatory signature in Settings before approving.', }, }); const shortReason = await api.inject({ method: 'POST', url: `/api/admin/applications/${submittedId}/reject`, headers, payload: { reason: 'too short' }, }); expect(shortReason.statusCode).toBe(400); }); it('approves a submitted application and records the decision', async () => { const api = await start(); const { cookie } = await login(api); const id = await insertApplication('SUBMITTED'); await putObject('settings/signatory-signature.png', png, 'image/png'); await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('settings/signatory-signature.png') }); const response = await api.inject({ method: 'POST', url: `/api/admin/applications/${id}/approve`, headers: { cookie }, }); expect(response.statusCode).toBe(200); expect(response.result).toMatchObject({ status: 'APPROVED' }); expect(JSON.stringify(response.result)).not.toContain('/survey/'); const saved = await db('applications').where({ id }).first(); expect(saved.final_pdf_key).toContain('/final.pdf'); expect(saved.signed_pdf_sha256).toHaveLength(64); expect(await db('survey_tokens').where({ application_id: id }).first()).toBeTruthy(); expect( await db('email_log').where({ application_id: id, template: 'approved' }).first(), ).toBeTruthy(); expect( await db('audit_log').where({ action: 'APPLICATION_APPROVED', entity_id: id }).first(), ).toBeTruthy(); expect(outbox[0].url).toContain('/survey/'); }); it('rejects with a reason, audits the decision, and rotates the resume token', async () => { const api = await start(); const { cookie } = await login(api); const id = await insertApplication('SUBMITTED'); const response = await api.inject({ method: 'POST', url: `/api/admin/applications/${id}/reject`, headers: { cookie }, payload: { reason: 'The registration number does not match' }, }); expect(response.statusCode).toBe(200); expect(response.result).toMatchObject({ status: 'REJECTED', rejection_reason: 'The registration number does not match', }); expect( await db('audit_log').where({ action: 'APPLICATION_REJECTED', entity_id: id }).first(), ).toBeTruthy(); expect(JSON.stringify(response.result)).not.toContain('/resume/'); const tokens = await db('resume_tokens').where({ application_id: id }); expect(tokens.filter((row) => row.revoked_at)).toHaveLength(1); expect(tokens.filter((row) => !row.revoked_at)).toHaveLength(1); const listed = await api.inject({ method: 'GET', url: '/api/admin/applications?status=REJECTED&q=Asha', headers: { cookie }, }); expect(listed.statusCode).toBe(200); expect(listed.result.total).toBe(1); const dashboard = await api.inject({ method: 'GET', url: '/api/admin/dashboard', headers: { cookie }, }); expect(dashboard.statusCode).toBe(200); expect(dashboard.result.counts.REJECTED).toBe(1); }); it('stops an admin from deactivating their own account', async () => { const api = await start(); const { cookie, admin } = await login(api); const response = await api.inject({ method: 'POST', url: `/api/admin/users/${admin.id}/deactivate`, headers: { cookie }, }); expect(response.statusCode).toBe(409); }); });