Admins and supervisors without 2FA are sent a code at their next login, and verifying it turns 2FA on. They can change the channel but not turn it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED, and promoting a user ends their sessions so they enrol at next login. The requirement is skipped when no email or WhatsApp channel can deliver a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.6 KiB
JavaScript
126 lines
4.6 KiB
JavaScript
process.env.JWT_SECRET = 'test-secret';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
// Two-factor is required for admins and supervisors — but only when some channel
|
|
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
|
|
|
|
const mockChannels = { smtp: true, whatsapp: true };
|
|
const mockUsers = new Map();
|
|
|
|
jest.mock('../src/config/db', () => ({
|
|
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
|
|
}));
|
|
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
|
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
|
|
jest.mock('../src/utils/whatsapp', () => ({
|
|
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
|
isConfigured: async () => mockChannels.whatsapp,
|
|
}));
|
|
|
|
const jwt = require('jsonwebtoken');
|
|
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
|
|
const { protect } = require('../src/middleware/authMiddleware');
|
|
|
|
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
|
|
|
|
beforeEach(() => {
|
|
mockChannels.smtp = true;
|
|
mockChannels.whatsapp = true;
|
|
mockUsers.clear();
|
|
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
|
|
});
|
|
|
|
describe('requiresTwoFactor', () => {
|
|
test('applies to admins and supervisors only', () => {
|
|
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
|
|
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
|
|
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
|
|
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
|
|
});
|
|
|
|
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
|
|
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
|
|
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('enforcementMethod', () => {
|
|
test('prefers email when SMTP is configured', async () => {
|
|
expect(await enforcementMethod(ADMIN)).toBe('email');
|
|
});
|
|
|
|
test('falls back to WhatsApp without SMTP', async () => {
|
|
mockChannels.smtp = false;
|
|
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
|
|
});
|
|
|
|
test('skips a guest placeholder email', async () => {
|
|
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
|
|
});
|
|
|
|
test('is null when no channel can deliver a code', async () => {
|
|
mockChannels.smtp = false;
|
|
mockChannels.whatsapp = false;
|
|
expect(await enforcementMethod(ADMIN)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('mustEnrolTwoFactor', () => {
|
|
test('true for an admin without 2FA', async () => {
|
|
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
|
|
});
|
|
|
|
test('false once enrolled, for other roles, or with no channel', async () => {
|
|
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
|
|
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
|
|
mockChannels.smtp = false;
|
|
mockChannels.whatsapp = false;
|
|
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('protect', () => {
|
|
function run(user, claims = {}) {
|
|
mockUsers.set(user.id, user);
|
|
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
|
|
const req = { headers: { authorization: `Bearer ${token}` } };
|
|
const res = { statusCode: 200, body: null };
|
|
res.status = (c) => { res.statusCode = c; return res; };
|
|
res.json = (b) => { res.body = b; return res; };
|
|
return new Promise((resolve) => {
|
|
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
|
|
if (res.body) resolve({ req, res, passed: false });
|
|
});
|
|
});
|
|
}
|
|
|
|
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
|
|
const { res, passed } = await run(ADMIN);
|
|
expect(passed).toBe(false);
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
|
|
});
|
|
|
|
test('lets an enrolled admin through', async () => {
|
|
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
|
|
expect(passed).toBe(true);
|
|
});
|
|
|
|
test('lets a regular user through without 2FA', async () => {
|
|
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
|
|
expect(passed).toBe(true);
|
|
});
|
|
|
|
test('gives the setup wizard token a grace period', async () => {
|
|
const { passed } = await run(ADMIN, { setup: true });
|
|
expect(passed).toBe(true);
|
|
});
|
|
|
|
test('does not enforce when no channel can deliver a code', async () => {
|
|
mockChannels.smtp = false;
|
|
mockChannels.whatsapp = false;
|
|
const { passed } = await run(ADMIN);
|
|
expect(passed).toBe(true);
|
|
});
|
|
});
|