367 lines
12 KiB
JavaScript
367 lines
12 KiB
JavaScript
import { afterEach, 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 { truncateTables } from './truncate.js';
|
|
|
|
vi.mock('../src/pdf/render.js', () => ({
|
|
renderPdf: vi.fn(async () => Buffer.from('%PDF-1.4')),
|
|
}));
|
|
|
|
const email = 'physician@example.com';
|
|
|
|
const complete = {
|
|
agreement_date: '2026-09-30',
|
|
full_name: 'Asha Rao',
|
|
age: 42,
|
|
address: 'Pune',
|
|
registration_no: 'MMC-12345',
|
|
medical_council: 'Maharashtra Medical Council',
|
|
pan: 'ABCDE1234F',
|
|
bank_name: 'State Bank of India',
|
|
account_holder: 'Asha Rao',
|
|
account_number: '123456789012',
|
|
ifsc: 'SBIN0001234',
|
|
accepted: true,
|
|
};
|
|
|
|
/**
|
|
* @param {string} url
|
|
*/
|
|
function tokenFrom(url) {
|
|
return url.split('/resume/')[1];
|
|
}
|
|
|
|
describe('applications', () => {
|
|
/** @type {import('@hapi/hapi').Server | undefined} */
|
|
let server;
|
|
|
|
afterEach(async () => {
|
|
await server?.stop();
|
|
clearOutbox();
|
|
await truncateTables();
|
|
});
|
|
|
|
async function start() {
|
|
server = await createServer();
|
|
return server;
|
|
}
|
|
|
|
async function postEmail(api, address = email) {
|
|
return api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications',
|
|
payload: { email: address },
|
|
});
|
|
}
|
|
|
|
async function postEmailWithCookie(api) {
|
|
return api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications',
|
|
headers: { cookie: 'admin_token=not-a-valid-iron-cookie' },
|
|
payload: { email },
|
|
});
|
|
}
|
|
|
|
it('creates a draft when the browser sends an invalid admin cookie', async () => {
|
|
const api = await start();
|
|
const created = await postEmailWithCookie(api);
|
|
|
|
expect(created.statusCode).toBe(200);
|
|
expect(created.result).toMatchObject({ status: 'DRAFT' });
|
|
});
|
|
|
|
it('creates a draft and resumes with the emailed token', async () => {
|
|
const api = await start();
|
|
const created = await postEmail(api);
|
|
|
|
expect(created.statusCode).toBe(200);
|
|
expect(created.result).toMatchObject({ status: 'DRAFT' });
|
|
expect(JSON.stringify(created.result)).not.toContain('/resume/');
|
|
|
|
const token = tokenFrom(outbox[0].url);
|
|
expect(created.result.token).toBe(token);
|
|
const me = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.result).toMatchObject({ email, status: 'DRAFT' });
|
|
|
|
const audit = await db('audit_log').where({ action: 'APPLICATION_CREATED' }).first();
|
|
expect(audit.actor_type).toBe('PHYSICIAN');
|
|
});
|
|
|
|
it('reuses a draft email and revokes the previous token', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const firstToken = tokenFrom(outbox[0].url);
|
|
await postEmail(api);
|
|
const secondToken = tokenFrom(outbox[1].url);
|
|
|
|
expect(await db('applications').count({ count: '*' }).first()).toMatchObject({ count: 1 });
|
|
|
|
const oldLink = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${firstToken}` },
|
|
});
|
|
const newLink = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${secondToken}` },
|
|
});
|
|
|
|
expect(oldLink.statusCode).toBe(401);
|
|
expect(newLink.statusCode).toBe(200);
|
|
});
|
|
|
|
it('saves a partial draft and rejects an incomplete finalize', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const token = tokenFrom(outbox[0].url);
|
|
const headers = { authorization: `Bearer ${token}` };
|
|
|
|
const saved = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me',
|
|
headers,
|
|
payload: { full_name: 'Asha Rao' },
|
|
});
|
|
expect(saved.statusCode).toBe(200);
|
|
expect(saved.result).toMatchObject({ full_name: 'Asha Rao' });
|
|
|
|
const finalize = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me?finalize=true',
|
|
headers,
|
|
payload: { full_name: 'Asha Rao', accepted: true },
|
|
});
|
|
expect(finalize.statusCode).toBe(400);
|
|
});
|
|
|
|
it('accepts a complete finalize and blocks a duplicate registration number', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const token = tokenFrom(outbox[0].url);
|
|
const saved = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me?finalize=true',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { ...complete, pan: 'abcde1234f' },
|
|
});
|
|
|
|
expect(saved.statusCode).toBe(200);
|
|
expect(saved.result).toMatchObject({ pan: 'ABCDE1234F', accepted: true });
|
|
const stored = await db('applications').where({ email }).first();
|
|
expect(String(stored.pan).startsWith('enc:v1:')).toBe(true);
|
|
expect(String(stored.account_number).startsWith('enc:v1:')).toBe(true);
|
|
expect(stored.pan_lookup).toHaveLength(64);
|
|
|
|
await postEmail(api, 'other@example.com');
|
|
const otherToken = tokenFrom(outbox[1].url);
|
|
const duplicate = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${otherToken}` },
|
|
payload: { registration_no: 'MMC-12345' },
|
|
});
|
|
|
|
expect(duplicate.statusCode).toBe(409);
|
|
expect(duplicate.result).toEqual({
|
|
error: {
|
|
code: 'CONFLICT',
|
|
message: 'This registration number is already in use',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns a rejected application to draft and voids its signature', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const application = await db('applications').where({ email }).first();
|
|
await db('applications').where({ id: application.id }).update({
|
|
status: 'REJECTED',
|
|
rejection_reason: 'Registration number could not be confirmed',
|
|
});
|
|
await db('signatures').insert({
|
|
application_id: application.id,
|
|
version: 1,
|
|
type: 'TYPED',
|
|
typed_text: 'Asha Rao',
|
|
signed_at: new Date(),
|
|
});
|
|
|
|
const response = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` },
|
|
payload: { full_name: 'Asha Rao' },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.result).toMatchObject({
|
|
status: 'DRAFT',
|
|
version: 2,
|
|
rejection_reason: 'Registration number could not be confirmed',
|
|
});
|
|
const signature = await db('signatures').where({ application_id: application.id }).first();
|
|
expect(signature.voided_at).toBeTruthy();
|
|
});
|
|
|
|
it('rejects edits after submission and unknown tokens', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const application = await db('applications').where({ email }).first();
|
|
await db('applications')
|
|
.where({ id: application.id })
|
|
.update({ status: 'SUBMITTED', draft_pdf_key: 'drafts/one.pdf' });
|
|
|
|
const submitted = await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` },
|
|
payload: { full_name: 'Asha Rao' },
|
|
});
|
|
const missing = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/applications/me',
|
|
headers: { authorization: 'Bearer not-a-real-token' },
|
|
});
|
|
|
|
expect(submitted.statusCode).toBe(409);
|
|
expect(missing.statusCode).toBe(401);
|
|
expect(missing.result).toEqual({
|
|
error: {
|
|
code: 'UNAUTHORIZED',
|
|
message: 'This resume link is invalid or has expired',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('rejects a signature when the application or consent is incomplete', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` };
|
|
|
|
const incomplete = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications/me/sign',
|
|
headers,
|
|
payload: { type: 'TYPED', typedName: 'Asha Rao' },
|
|
});
|
|
expect(incomplete.statusCode).toBe(400);
|
|
|
|
await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me?finalize=true',
|
|
headers,
|
|
payload: complete,
|
|
});
|
|
const missingConsent = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications/me/sign',
|
|
headers,
|
|
payload: { type: 'TYPED', typedName: 'Asha Rao' },
|
|
});
|
|
expect(missingConsent.statusCode).toBe(400);
|
|
|
|
// Ensure auto-approve cannot run so this case stays SUBMITTED.
|
|
await db('settings')
|
|
.where({ key: 'signatory_signature_key' })
|
|
.update({ value: JSON.stringify('') });
|
|
|
|
const signed = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications/me/sign',
|
|
headers,
|
|
payload: { type: 'TYPED', typedName: 'Asha Rao' },
|
|
});
|
|
|
|
expect(signed.statusCode).toBe(200);
|
|
// Without a CREST signatory image, auto-approve fails and the row stays SUBMITTED.
|
|
expect(signed.result).toMatchObject({ status: 'SUBMITTED' });
|
|
expect(signed.result.signedPdfUrl).toEqual(expect.any(String));
|
|
const row = await db('applications').where({ email }).first();
|
|
expect(row.signed_pdf_sha256).toHaveLength(64);
|
|
expect(row.submitted_at).toBeTruthy();
|
|
});
|
|
|
|
it('auto-approves on sign when the CREST signature is configured', async () => {
|
|
const { putObject } = await import('../src/services/storage.js');
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const headers = { authorization: `Bearer ${tokenFrom(outbox[0].url)}` };
|
|
|
|
await api.inject({
|
|
method: 'PUT',
|
|
url: '/api/applications/me?finalize=true',
|
|
headers,
|
|
payload: complete,
|
|
});
|
|
|
|
const png = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
|
'base64',
|
|
);
|
|
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 signed = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications/me/sign',
|
|
headers,
|
|
payload: { type: 'TYPED', typedName: 'Asha Rao' },
|
|
});
|
|
|
|
expect(signed.statusCode).toBe(200);
|
|
expect(signed.result).toMatchObject({ status: 'APPROVED' });
|
|
expect(signed.result.surveyToken).toEqual(expect.any(String));
|
|
const row = await db('applications').where({ email }).first();
|
|
expect(row.status).toBe('APPROVED');
|
|
expect(row.final_pdf_key).toBeTruthy();
|
|
expect(row.decided_by).toBeNull();
|
|
expect(
|
|
await db('audit_log').where({ action: 'APPLICATION_AUTO_APPROVED' }).first(),
|
|
).toBeTruthy();
|
|
expect(await db('survey_tokens').where({ application_id: row.id }).first()).toBeTruthy();
|
|
});
|
|
|
|
it('returns a rejected application to draft from edit and keeps the reason', async () => {
|
|
const api = await start();
|
|
await postEmail(api);
|
|
const application = await db('applications').where({ email }).first();
|
|
await db('applications').where({ id: application.id }).update({
|
|
status: 'REJECTED',
|
|
rejection_reason: 'Please correct the bank details',
|
|
});
|
|
await db('signatures').insert({
|
|
application_id: application.id,
|
|
version: 1,
|
|
type: 'TYPED',
|
|
typed_text: 'Asha Rao',
|
|
signed_at: new Date(),
|
|
});
|
|
|
|
const response = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/applications/me/edit',
|
|
headers: { authorization: `Bearer ${tokenFrom(outbox[0].url)}` },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.result).toMatchObject({
|
|
status: 'DRAFT',
|
|
version: 2,
|
|
rejection_reason: 'Please correct the bank details',
|
|
});
|
|
const signature = await db('signatures').where({ application_id: application.id }).first();
|
|
expect(signature.voided_at).toBeTruthy();
|
|
});
|
|
});
|