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