process.env.JWT_SECRET = 'test-secret'; process.env.NODE_ENV = 'test'; // ─── In-memory stand-in for the two Prisma models the 2FA helpers use ───────── // Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }. function matches(row, where = {}) { return Object.entries(where).every(([key, cond]) => { const value = row[key]; if (cond && typeof cond === 'object' && !(cond instanceof Date)) { if ('gt' in cond) return value > cond.gt; if ('lt' in cond) return value < cond.lt; } if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime(); return value === cond; }); } function applyData(row, data) { for (const [key, val] of Object.entries(data)) { row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val; } } function makeModel(defaults, relations = {}) { let rows = []; let seq = 0; const withRelations = (row, include) => { if (!row) return null; const out = { ...row }; for (const key of Object.keys(include || {})) out[key] = relations[key](row); return out; }; return { _rows: () => rows, _reset: () => { rows = []; }, create: async ({ data }) => { const row = { id: `id-${++seq}`, ...defaults(), ...data }; rows.push(row); return { ...row }; }, findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include), update: async ({ where, data }) => { const row = rows.find(r => matches(r, where)); applyData(row, data); return { ...row }; }, updateMany: async ({ where, data }) => { const hit = rows.filter(r => matches(r, where)); hit.forEach(r => applyData(r, data)); return { count: hit.length }; }, deleteMany: async ({ where }) => { const before = rows.length; rows = rows.filter(r => !matches(r, where)); return { count: before - rows.length }; }, }; } const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' }; const mockDb = { twoFactorChallenge: makeModel( () => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }), { user: () => USER }, ), trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })), }; const mockSent = { emails: [], whatsapps: [] }; const mockWhatsApp = { configured: true, fail: false }; jest.mock('../src/config/db', () => mockDb); jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback })); jest.mock('../src/utils/email', () => ({ sendMail: async (mail) => { mockSent.emails.push(mail); }, isSmtpConfigured: () => true, buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }), })); jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` })); jest.mock('../src/utils/whatsapp', () => ({ isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')), isConfigured: async () => mockWhatsApp.configured, sendText: async (to, message) => { if (mockWhatsApp.fail) throw new Error('session disconnected'); mockSent.whatsapps.push({ to, message }); }, })); const { MAX_ATTEMPTS, availableMethods, createChallenge, verifyChallenge, resendChallenge, issueTrustedDevice, isTrustedDevice, clearTrustedDevices, } = require('../src/utils/twoFactor'); const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1]; const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1]; const wrongCode = (code) => (code === '000000' ? '111111' : '000000'); beforeEach(() => { mockDb.twoFactorChallenge._reset(); mockDb.trustedDevice._reset(); mockSent.emails = []; mockSent.whatsapps = []; mockWhatsApp.configured = true; mockWhatsApp.fail = false; jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'info').mockImplementation(() => {}); }); afterEach(() => jest.restoreAllMocks()); describe('availableMethods', () => { test('offers WhatsApp only with a valid phone and a configured gateway', async () => { expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']); mockWhatsApp.configured = false; expect(await availableMethods(USER)).toEqual(['email']); mockWhatsApp.configured = true; expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']); }); test('offers nothing by email for a guest placeholder address', async () => { expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']); }); }); describe('createChallenge', () => { test('emails the code and stores only its hash', async () => { const res = await createChallenge(USER, 'login', 'email'); expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' }); const [row] = mockDb.twoFactorChallenge._rows(); expect(row.id).toBe(res.challengeId); expect(row.codeHash).not.toContain(lastEmailCode()); expect(mockSent.emails[0].to).toBe(USER.email); }); test('sends by WhatsApp when asked', async () => { const res = await createChallenge(USER, 'login', 'whatsapp'); expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' }); expect(mockSent.whatsapps).toHaveLength(1); expect(mockSent.emails).toHaveLength(0); }); test('falls back to email when WhatsApp fails or is not configured', async () => { mockWhatsApp.fail = true; expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email'); mockWhatsApp.fail = false; mockWhatsApp.configured = false; expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email'); expect(mockSent.emails).toHaveLength(2); }); test('does not fall back when allowFallback is false, and records no challenge', async () => { mockWhatsApp.fail = true; await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 }); expect(mockSent.emails).toHaveLength(0); expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0); }); test('a new challenge replaces the previous one for the same purpose', async () => { const first = await createChallenge(USER, 'login', 'email'); const firstCode = lastEmailCode(); await createChallenge(USER, 'login', 'email'); await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/); }); }); describe('verifyChallenge', () => { test('accepts the right code exactly once', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); const code = lastEmailCode(); const challenge = await verifyChallenge(challengeId, code, 'login'); expect(challenge.userId).toBe(USER.id); await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); }); test('tolerates spaces in the submitted code', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); const code = lastEmailCode(); await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy(); }); test('locks the challenge after the maximum wrong attempts, even for the right code', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); const code = lastEmailCode(); for (let i = 1; i <= MAX_ATTEMPTS; i++) { await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow( i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/ ); } await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); }); test('rejects an expired code', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000); await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/); }); test('rejects a code used for the wrong purpose or by another user', async () => { const { challengeId } = await createChallenge(USER, 'enable', 'email'); const code = lastEmailCode(); await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/); await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/); await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy(); }); test('rejects missing or malformed input', async () => { await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow(); await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow(); const { challengeId } = await createChallenge(USER, 'login', 'email'); await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/); }); }); describe('resendChallenge', () => { test('refuses inside the cooldown', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 }); expect(mockSent.emails).toHaveLength(1); }); test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); const oldCode = lastEmailCode(); await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow(); mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000); await resendChallenge(challengeId, 'login'); const newCode = lastEmailCode(); expect(mockSent.emails).toHaveLength(2); expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0); if (newCode !== oldCode) { await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/); } await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy(); }); test('refuses once the challenge is too old or already used', async () => { const { challengeId } = await createChallenge(USER, 'login', 'email'); const row = mockDb.twoFactorChallenge._rows()[0]; row.lastSentAt = new Date(Date.now() - 61 * 1000); row.createdAt = new Date(Date.now() - 31 * 60 * 1000); await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/); row.createdAt = new Date(); row.usedAt = new Date(); await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/); }); }); describe('trusted devices', () => { test('a freshly issued token is trusted for its own user only', async () => { const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36'); expect(token).toMatch(/^[0-9a-f]{64}$/); expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token); expect(await isTrustedDevice(USER.id, token)).toBe(true); expect(await isTrustedDevice('someone-else', token)).toBe(false); }); test('unknown, malformed and expired tokens are not trusted', async () => { const token = await issueTrustedDevice(USER.id, ''); expect(await isTrustedDevice(USER.id, 'nope')).toBe(false); expect(await isTrustedDevice(USER.id, undefined)).toBe(false); expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false); mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000); expect(await isTrustedDevice(USER.id, token)).toBe(false); }); test('clearTrustedDevices forgets every device for the user', async () => { const a = await issueTrustedDevice(USER.id, ''); const b = await issueTrustedDevice(USER.id, ''); await clearTrustedDevices(USER.id); expect(await isTrustedDevice(USER.id, a)).toBe(false); expect(await isTrustedDevice(USER.id, b)).toBe(false); }); });