Files
hope-events/backend/tests/twoFactorFlow.test.js
T
joshuaandClaude Opus 5.5 c3012cb3f6 Require two-factor authentication for admins and supervisors
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>
2026-10-19 11:00:12 +02:00

283 lines
12 KiB
JavaScript

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