import { 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 { lookupSurveyToken } from '../src/services/surveyTokens.js'; import { seedSurveyDefinition } from '../src/survey/seed.js'; import { putObject } from '../src/services/storage.js'; import { truncateTables } from './truncate.js'; const mailState = vi.hoisted(() => ({ send: vi.fn(async () => [{ headers: { 'x-message-id': 'msg-1' } }]), setApiKey: vi.fn(), })); vi.mock('@sendgrid/mail', () => ({ default: { setApiKey: (...args) => mailState.setApiKey(...args), send: (...args) => mailState.send(...args), }, })); const webhookState = vi.hoisted(() => ({ valid: true })); vi.mock('@sendgrid/eventwebhook', () => ({ EventWebhook: class { convertPublicKeyToECDSA() { return {}; } verifySignature() { return webhookState.valid; } }, EventWebhookHeader: { SIGNATURE: () => 'X-Twilio-Email-Event-Webhook-Signature', TIMESTAMP: () => 'X-Twilio-Email-Event-Webhook-Timestamp', }, })); vi.mock('../src/pdf/render.js', () => ({ renderPdf: vi.fn(async () => Buffer.from('%PDF-1.4')), })); const email = 'mail-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]; } /** * @param {string} body */ function surveyTokenFrom(body) { const match = body.match(/\/survey\/([A-Za-z0-9_-]+)/); return match?.[1] ?? ''; } describe('agreement email', () => { /** @type {import('@hapi/hapi').Server | undefined} */ let server; beforeEach(async () => { process.env.MAIL_FORCE_SEND = 'true'; process.env.MAIL_FROM = 'from@example.com'; mailState.send.mockReset(); mailState.send.mockResolvedValue([{ headers: { 'x-message-id': 'msg-1' } }]); webhookState.valid = true; await seedSurveyDefinition(); await db('settings') .where({ key: 'signatory_signature_key' }) .update({ value: JSON.stringify('') }); }); afterEach(async () => { delete process.env.MAIL_FORCE_SEND; delete process.env.MAIL_FROM; delete process.env.SENDGRID_WEBHOOK_VERIFICATION_KEY; await server?.stop(); 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 sessionCookie(response); } async function insertSubmitted() { const [id] = await db('applications').insert({ public_id: randomUUID(), email: 'physician@example.com', status: 'SUBMITTED', full_name: 'Asha Rao', agreement_date: '2026-09-30', submitted_at: new Date(), version: 1, }); await db('signatures').insert({ application_id: id, version: 1, type: 'TYPED', typed_text: 'Asha Rao', signed_at: new Date(), }); return id; } async function configureSignature() { 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') }); } it('emails a survey link that validates once', async () => { const api = await start(); const cookie = await login(api); const id = await insertSubmitted(); await configureSignature(); const response = await api.inject({ method: 'POST', url: `/api/admin/applications/${id}/approve`, headers: { cookie }, }); expect(response.statusCode).toBe(200); const message = mailState.send.mock.calls[0][0]; expect(message.html).toContain('/survey/'); expect(message.text).toContain('one submission'); const token = surveyTokenFrom(message.text); expect(await lookupSurveyToken(token)).toBeTruthy(); expect(JSON.stringify(response.result)).not.toContain(token); const first = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); const second = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(first.statusCode).toBe(200); expect(first.result.version).toBe(2); expect(second.statusCode).toBe(200); expect(second.result.version).toBe(2); await db('survey_tokens').where({ application_id: id }).update({ used_at: new Date() }); const used = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(used.statusCode).toBe(401); expect(used.result.error.message).toBe('This survey has already been submitted'); expect(await lookupSurveyToken(token)).toBeNull(); await db('survey_tokens').where({ application_id: id }).update({ used_at: null, revoked_at: new Date(), }); const revoked = await api.inject({ method: 'GET', url: `/api/survey/not-a-token` }); const revokedKnown = await api.inject({ method: 'GET', url: `/api/survey/${token}` }); expect(revoked.result.error.message).toBe(revokedKnown.result.error.message); expect(await lookupSurveyToken(token)).toBeNull(); }); it('keeps the approval when SendGrid rejects the message', async () => { mailState.send.mockRejectedValue(Object.assign(new Error('mailbox refused'), { code: 400 })); const api = await start(); const cookie = await login(api); const id = await insertSubmitted(); await configureSignature(); const response = await api.inject({ method: 'POST', url: `/api/admin/applications/${id}/approve`, headers: { cookie }, }); expect(response.statusCode).toBe(200); expect(response.result.status).toBe('APPROVED'); const saved = await db('applications').where({ id }).first(); expect(saved.status).toBe('APPROVED'); const emailLog = await db('email_log') .where({ application_id: id, template: 'approved' }) .first(); expect(emailLog.status).toBe('failed'); }); it('records delivery events and rejects a bad signature', async () => { const api = await start(); const [applicationId] = await db('applications').insert({ public_id: randomUUID(), email: 'physician@example.com', status: 'APPROVED', version: 1, }); await db('email_log').insert({ application_id: applicationId, template: 'approved', to_email: 'physician@example.com', sendgrid_message_id: 'msg-abc', status: 'sent', created_at: new Date(), }); process.env.SENDGRID_WEBHOOK_VERIFICATION_KEY = 'test-key'; const payload = JSON.stringify([ { event: 'delivered', sg_message_id: 'msg-abc.filter' }, { event: 'open', sg_message_id: 'msg-abc.filter' }, ]); const delivered = await api.inject({ method: 'POST', url: '/api/webhooks/sendgrid', headers: { 'x-twilio-email-event-webhook-signature': 'sig', 'x-twilio-email-event-webhook-timestamp': '1', }, payload, }); expect(delivered.statusCode).toBe(200); const row = await db('email_log').where({ sendgrid_message_id: 'msg-abc' }).first(); expect(row.status).toBe('delivered'); webhookState.valid = false; const rejected = await api.inject({ method: 'POST', url: '/api/webhooks/sendgrid', headers: { 'x-twilio-email-event-webhook-signature': 'sig', 'x-twilio-email-event-webhook-timestamp': '1', }, payload: JSON.stringify([{ event: 'bounce', sg_message_id: 'msg-abc.filter' }]), }); expect(rejected.statusCode).toBe(401); expect((await db('email_log').where({ sendgrid_message_id: 'msg-abc' }).first()).status).toBe( 'delivered', ); }); });