217 lines
6.9 KiB
JavaScript
217 lines
6.9 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { afterEach, describe, expect, it } 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';
|
|
|
|
const email = 'doctor@example.com';
|
|
|
|
/**
|
|
* @param {import('@hapi/hapi').ServerInjectResponse} response
|
|
* @param {string} name
|
|
*/
|
|
function cookieNamed(response, name) {
|
|
const raw = response.headers['set-cookie'];
|
|
const headers = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
const match = headers.find((header) => header.startsWith(`${name}=`));
|
|
return match ? match.split(';')[0] : null;
|
|
}
|
|
|
|
describe('user OTP auth', () => {
|
|
/** @type {import('@hapi/hapi').Server | undefined} */
|
|
let server;
|
|
|
|
afterEach(async () => {
|
|
clearOutbox();
|
|
await server?.stop();
|
|
await truncateTables();
|
|
});
|
|
|
|
async function start() {
|
|
await db('pppaimt_doctors').insert({ email, hcp_name: 'Dr Test' });
|
|
server = await createServer();
|
|
return server;
|
|
}
|
|
|
|
it('requests an OTP, verifies it, returns /api/me, and logs out', async () => {
|
|
const api = await start();
|
|
|
|
const requested = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/request',
|
|
payload: { email },
|
|
});
|
|
|
|
expect(requested.statusCode).toBe(200);
|
|
expect(requested.result).toMatchObject({ ok: true });
|
|
|
|
const user = await db('users').where({ email }).first();
|
|
expect(user).toBeTruthy();
|
|
|
|
const application = await db('applications').where({ user_id: user.id }).first();
|
|
expect(application).toMatchObject({ email, status: 'DRAFT' });
|
|
|
|
expect(outbox).toHaveLength(1);
|
|
expect(outbox[0]?.template).toBe('login_otp');
|
|
expect(outbox[0]?.otp).toMatch(/^\d{6}$/);
|
|
|
|
const verified = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/verify',
|
|
payload: { email, otp: outbox[0].otp },
|
|
});
|
|
|
|
expect(verified.statusCode).toBe(200);
|
|
expect(verified.result).toMatchObject({
|
|
user: { email },
|
|
me: { nextStep: 'consent' },
|
|
});
|
|
|
|
const session = cookieNamed(verified, 'user_token');
|
|
expect(session).toBeTruthy();
|
|
|
|
const me = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/me',
|
|
headers: { cookie: session },
|
|
});
|
|
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.result).toMatchObject({
|
|
user: { email },
|
|
steps: {
|
|
consent: { done: false },
|
|
agreement: { done: false },
|
|
survey: { done: false },
|
|
},
|
|
nextStep: 'consent',
|
|
});
|
|
|
|
const logout = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/logout',
|
|
headers: { cookie: session },
|
|
});
|
|
|
|
expect(logout.statusCode).toBe(200);
|
|
expect(cookieNamed(logout, 'user_token')).toMatch(/user_token=/);
|
|
expect(String(logout.headers['set-cookie'])).toMatch(/Max-Age=0|Expires=/i);
|
|
|
|
const missing = await api.inject({ method: 'GET', url: '/api/me' });
|
|
expect(missing.statusCode).toBe(401);
|
|
expect(await db('audit_log').where({ action: 'USER_LOGOUT' }).first()).toBeTruthy();
|
|
});
|
|
|
|
it('clears the cookie even without a valid session', async () => {
|
|
const api = await start();
|
|
|
|
const logout = await api.inject({ method: 'POST', url: '/api/auth/logout' });
|
|
|
|
expect(logout.statusCode).toBe(200);
|
|
expect(logout.result).toEqual({ ok: true });
|
|
expect(String(logout.headers['set-cookie'])).toMatch(/user_token=.*(Max-Age=0|Expires=)/i);
|
|
expect(await db('audit_log').where({ action: 'USER_LOGOUT' }).first()).toBeUndefined();
|
|
});
|
|
|
|
it('rejects an invalid OTP', async () => {
|
|
const api = await start();
|
|
|
|
await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/request',
|
|
payload: { email },
|
|
});
|
|
|
|
const verified = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/verify',
|
|
payload: { email, otp: '000000' },
|
|
});
|
|
|
|
expect(verified.statusCode).toBe(401);
|
|
});
|
|
|
|
it('refuses an email that is not in pppaimt_doctors without creating anything', async () => {
|
|
const api = await start();
|
|
const stranger = 'not-a-doctor@example.com';
|
|
|
|
const requested = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/request',
|
|
payload: { email: stranger },
|
|
});
|
|
|
|
expect(requested.statusCode).toBe(403);
|
|
expect(requested.result.error.message).toBe('Invalid User!');
|
|
expect(await db('users').where({ email: stranger }).first()).toBeUndefined();
|
|
expect(await db('applications').where({ email: stranger }).first()).toBeUndefined();
|
|
expect(await db('login_otps').where({ email: stranger }).first()).toBeUndefined();
|
|
expect(outbox).toHaveLength(0);
|
|
|
|
const verified = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/verify',
|
|
payload: { email: stranger, otp: '123456' },
|
|
});
|
|
|
|
expect(verified.statusCode).toBe(403);
|
|
});
|
|
|
|
it('fills the user and draft application name from HCP NAME without a leading Dr', async () => {
|
|
const api = await start();
|
|
const doctorEmail = 'tilve@example.com';
|
|
await db('pppaimt_doctors').insert({ email: doctorEmail, hcp_name: 'Dr. Sandeep S Tilve' });
|
|
|
|
await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: doctorEmail } });
|
|
|
|
const user = await db('users').where({ email: doctorEmail }).first();
|
|
expect(user.name).toBe('Sandeep S Tilve');
|
|
const application = await db('applications').where({ user_id: user.id }).first();
|
|
expect(application.full_name).toBe('Sandeep S Tilve');
|
|
expect(application.account_holder).toBeNull();
|
|
});
|
|
|
|
it('keeps names the doctor already entered', async () => {
|
|
const api = await start();
|
|
const [userId] = await db('users').insert({ email, name: 'Typed Name', is_active: true });
|
|
await db('applications').insert({
|
|
public_id: randomUUID(),
|
|
email,
|
|
status: 'DRAFT',
|
|
user_id: userId,
|
|
full_name: 'Typed Full Name',
|
|
});
|
|
|
|
await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email } });
|
|
|
|
expect((await db('users').where({ id: userId }).first()).name).toBe('Typed Name');
|
|
expect((await db('applications').where({ user_id: userId }).first()).full_name).toBe(
|
|
'Typed Full Name',
|
|
);
|
|
});
|
|
|
|
it('does not strip names that only start with the letters Dr', async () => {
|
|
const api = await start();
|
|
const doctorEmail = 'drew@example.com';
|
|
await db('pppaimt_doctors').insert({ email: doctorEmail, hcp_name: 'Drew Mathews' });
|
|
|
|
await api.inject({ method: 'POST', url: '/api/auth/otp/request', payload: { email: doctorEmail } });
|
|
|
|
expect((await db('users').where({ email: doctorEmail }).first()).name).toBe('Drew Mathews');
|
|
});
|
|
|
|
it('matches the doctor email regardless of case', async () => {
|
|
const api = await start();
|
|
|
|
const requested = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/auth/otp/request',
|
|
payload: { email: 'Doctor@Example.COM' },
|
|
});
|
|
|
|
expect(requested.statusCode).toBe(200);
|
|
expect(outbox).toHaveLength(1);
|
|
});
|
|
});
|