214 lines
6.8 KiB
JavaScript
214 lines
6.8 KiB
JavaScript
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);
|
|
});
|
|
});
|