260 lines
8.3 KiB
JavaScript
260 lines
8.3 KiB
JavaScript
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',
|
|
);
|
|
});
|
|
});
|