187 lines
5.3 KiB
JavaScript
187 lines
5.3 KiB
JavaScript
import argon2 from 'argon2';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { createServer } from '../src/app.js';
|
|
import { db } from '../src/db/knex.js';
|
|
import { truncateTables } from './truncate.js';
|
|
|
|
const email = 'admin@example.com';
|
|
const password = 'correct-password';
|
|
|
|
/**
|
|
* MySQL may return a JSON column as a parsed value or as text.
|
|
* @param {unknown} value
|
|
*/
|
|
function parseJson(value) {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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 auth', () => {
|
|
/** @type {import('@hapi/hapi').Server | undefined} */
|
|
let server;
|
|
|
|
afterEach(async () => {
|
|
await server?.stop();
|
|
await truncateTables();
|
|
});
|
|
|
|
async function start() {
|
|
server = await createServer();
|
|
return server;
|
|
}
|
|
|
|
async function createAdmin(overrides = {}) {
|
|
await db('admin_users').insert({
|
|
name: 'Administrator',
|
|
email,
|
|
password_hash: await argon2.hash(password, { type: argon2.argon2id }),
|
|
is_active: true,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
it('seeds signatory settings', async () => {
|
|
const rows = await db('settings').select('key', 'value');
|
|
const settings = Object.fromEntries(rows.map((row) => [row.key, parseJson(row.value)]));
|
|
|
|
expect(settings.signatory_name).toBe('Dr Sudeep Salvi');
|
|
expect(settings.signatory_designation).toBe('');
|
|
expect(settings.signatory_signature_key).toBe('');
|
|
expect(settings.mail_from_name).toBe('');
|
|
});
|
|
|
|
it('logs in, returns the admin, and logs out', async () => {
|
|
await createAdmin();
|
|
const api = await start();
|
|
|
|
const login = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password },
|
|
});
|
|
|
|
expect(login.statusCode).toBe(200);
|
|
expect(login.result).toMatchObject({ name: 'Administrator', email });
|
|
const cookie = sessionCookie(login);
|
|
expect(cookie.startsWith('admin_token=')).toBe(true);
|
|
expect(login.headers['set-cookie'][0]).toMatch(/HttpOnly/i);
|
|
expect(login.headers['set-cookie'][0]).toMatch(/SameSite=Lax/i);
|
|
|
|
const me = await api.inject({
|
|
method: 'GET',
|
|
url: '/api/admin/auth/me',
|
|
headers: { cookie },
|
|
});
|
|
expect(me.statusCode).toBe(200);
|
|
expect(me.result).toMatchObject({ email });
|
|
|
|
const logout = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/logout',
|
|
headers: { cookie },
|
|
});
|
|
expect(logout.statusCode).toBe(200);
|
|
|
|
const audits = await db('audit_log').select('action').orderBy('id');
|
|
expect(audits.map((row) => row.action)).toEqual(['ADMIN_LOGIN', 'ADMIN_LOGOUT']);
|
|
});
|
|
|
|
it('rejects a missing session', async () => {
|
|
const api = await start();
|
|
const response = await api.inject({ method: 'GET', url: '/api/admin/auth/me' });
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
expect(response.result).toEqual({
|
|
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
|
|
});
|
|
});
|
|
|
|
it('uses the same error for an unknown email and a wrong password', async () => {
|
|
await createAdmin();
|
|
const api = await start();
|
|
|
|
const unknown = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email: 'missing@example.com', password },
|
|
});
|
|
const wrong = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password: 'wrong-password' },
|
|
});
|
|
|
|
expect(unknown.statusCode).toBe(401);
|
|
expect(wrong.statusCode).toBe(401);
|
|
expect(unknown.result).toEqual(wrong.result);
|
|
});
|
|
|
|
it('locks the account on the fifth failure', async () => {
|
|
await createAdmin();
|
|
const api = await start();
|
|
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const response = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password: 'wrong-password' },
|
|
});
|
|
expect(response.statusCode).toBe(401);
|
|
}
|
|
|
|
const locked = await db('admin_users').where({ email }).first();
|
|
expect(locked.failed_login_count).toBe(0);
|
|
expect(new Date(locked.locked_until).getTime()).toBeGreaterThan(Date.now());
|
|
|
|
const duringLock = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password },
|
|
});
|
|
expect(duringLock.statusCode).toBe(401);
|
|
|
|
await db('admin_users')
|
|
.where({ email })
|
|
.update({ locked_until: new Date(Date.now() - 1000) });
|
|
|
|
const afterLock = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password },
|
|
});
|
|
expect(afterLock.statusCode).toBe(200);
|
|
|
|
const cleared = await db('admin_users').where({ email }).first();
|
|
expect(cleared.failed_login_count).toBe(0);
|
|
expect(cleared.locked_until).toBeNull();
|
|
});
|
|
|
|
it('rejects an inactive admin', async () => {
|
|
await createAdmin({ is_active: false });
|
|
const api = await start();
|
|
const response = await api.inject({
|
|
method: 'POST',
|
|
url: '/api/admin/auth/login',
|
|
payload: { email, password },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
});
|
|
});
|