PPPAIMT-Crest/backend/test/applications.test.js

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();
});
});