const request = require('supertest'); const express = require('express'); // Mock dependencies before requiring routes jest.mock('../../../models', () => ({ User: { findByPk: jest.fn(), }, })); jest.mock('../../../services/TwoFactorService', () => ({ generateTotpSecret: jest.fn(), generateRecoveryCodes: jest.fn(), })); jest.mock('../../../services/email', () => ({ auth: { sendTwoFactorEnabledEmail: jest.fn(), sendTwoFactorOtpEmail: jest.fn(), sendRecoveryCodeUsedEmail: jest.fn(), sendTwoFactorDisabledEmail: jest.fn(), }, })); jest.mock('../../../middleware/auth', () => ({ authenticateToken: (req, res, next) => { req.user = { id: 'user-123' }; next(); }, })); jest.mock('../../../middleware/stepUpAuth', () => ({ requireStepUpAuth: () => (req, res, next) => next(), })); jest.mock('../../../middleware/csrf', () => ({ csrfProtection: (req, res, next) => next(), })); jest.mock('../../../middleware/validation', () => ({ sanitizeInput: (req, res, next) => next(), validateTotpCode: (req, res, next) => next(), validateEmailOtp: (req, res, next) => next(), validateRecoveryCode: (req, res, next) => next(), })); jest.mock('../../../middleware/rateLimiter', () => ({ twoFactorVerificationLimiter: (req, res, next) => next(), twoFactorSetupLimiter: (req, res, next) => next(), recoveryCodeLimiter: (req, res, next) => next(), emailOtpSendLimiter: (req, res, next) => next(), })); jest.mock('../../../utils/logger', () => ({ info: jest.fn(), error: jest.fn(), warn: jest.fn(), })); const { User } = require('../../../models'); const TwoFactorService = require('../../../services/TwoFactorService'); const emailServices = require('../../../services/email'); const twoFactorRoutes = require('../../../routes/twoFactor'); describe('Two Factor Routes', () => { let app; beforeEach(() => { app = express(); app.use(express.json()); app.use('/2fa', twoFactorRoutes); jest.clearAllMocks(); }); // ============================================ // SETUP ENDPOINTS // ============================================ describe('POST /2fa/setup/totp/init', () => { it('should initialize TOTP setup and return QR code', async () => { const mockUser = { id: 'user-123', email: 'test@example.com', twoFactorEnabled: false, storePendingTotpSecret: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); TwoFactorService.generateTotpSecret.mockResolvedValue({ qrCodeDataUrl: 'data:image/png;base64,test', encryptedSecret: 'encrypted-secret', encryptedSecretIv: 'iv-123', }); const response = await request(app) .post('/2fa/setup/totp/init'); expect(response.status).toBe(200); expect(response.body.qrCodeDataUrl).toBe('data:image/png;base64,test'); expect(response.body.message).toContain('Scan the QR code'); expect(mockUser.storePendingTotpSecret).toHaveBeenCalledWith('encrypted-secret', 'iv-123'); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/2fa/setup/totp/init'); expect(response.status).toBe(404); expect(response.body.error).toBe('User not found'); }); it('should return 400 when 2FA already enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: true, }); const response = await request(app) .post('/2fa/setup/totp/init'); expect(response.status).toBe(400); expect(response.body.error).toContain('already enabled'); }); it('should handle errors during setup', async () => { User.findByPk.mockRejectedValue(new Error('Database error')); const response = await request(app) .post('/2fa/setup/totp/init'); expect(response.status).toBe(500); expect(response.body.error).toContain('Failed to initialize'); }); }); describe('POST /2fa/setup/totp/verify', () => { it('should verify TOTP code and enable 2FA', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, twoFactorSetupPendingSecret: 'pending-secret', verifyPendingTotpCode: jest.fn().mockReturnValue(true), enableTotp: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); TwoFactorService.generateRecoveryCodes.mockResolvedValue({ codes: ['XXXX-YYYY', 'AAAA-BBBB'], }); emailServices.auth.sendTwoFactorEnabledEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(200); expect(response.body.message).toContain('enabled successfully'); expect(response.body.recoveryCodes).toHaveLength(2); expect(response.body.warning).toContain('Save these recovery codes'); expect(mockUser.enableTotp).toHaveBeenCalled(); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(404); }); it('should return 400 when 2FA already enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: true, }); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(400); }); it('should return 400 when no pending secret', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, twoFactorSetupPendingSecret: null, }); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(400); expect(response.body.error).toContain('No pending TOTP setup'); }); it('should return 400 for invalid code', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, twoFactorSetupPendingSecret: 'pending-secret', verifyPendingTotpCode: jest.fn().mockReturnValue(false), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(400); expect(response.body.error).toContain('Invalid verification code'); }); it('should continue even if email fails', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, twoFactorSetupPendingSecret: 'pending-secret', verifyPendingTotpCode: jest.fn().mockReturnValue(true), enableTotp: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); TwoFactorService.generateRecoveryCodes.mockResolvedValue({ codes: ['XXXX-YYYY'] }); emailServices.auth.sendTwoFactorEnabledEmail.mockRejectedValue(new Error('Email failed')); const response = await request(app) .post('/2fa/setup/totp/verify') .send({ code: '123456' }); expect(response.status).toBe(200); }); }); describe('POST /2fa/setup/email/init', () => { it('should send email OTP for setup', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, generateEmailOtp: jest.fn().mockResolvedValue('123456'), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendTwoFactorOtpEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/setup/email/init'); expect(response.status).toBe(200); expect(response.body.message).toContain('Verification code sent'); expect(emailServices.auth.sendTwoFactorOtpEmail).toHaveBeenCalled(); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/2fa/setup/email/init'); expect(response.status).toBe(404); }); it('should return 400 when 2FA already enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: true, }); const response = await request(app) .post('/2fa/setup/email/init'); expect(response.status).toBe(400); }); it('should return 500 when email fails', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, generateEmailOtp: jest.fn().mockResolvedValue('123456'), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendTwoFactorOtpEmail.mockRejectedValue(new Error('Email failed')); const response = await request(app) .post('/2fa/setup/email/init'); expect(response.status).toBe(500); expect(response.body.error).toContain('Failed to send verification email'); }); }); describe('POST /2fa/setup/email/verify', () => { it('should verify email OTP and enable email 2FA', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, isEmailOtpLocked: jest.fn().mockReturnValue(false), verifyEmailOtp: jest.fn().mockReturnValue(true), enableEmailTwoFactor: jest.fn().mockResolvedValue(), clearEmailOtp: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); TwoFactorService.generateRecoveryCodes.mockResolvedValue({ codes: ['XXXX-YYYY'] }); emailServices.auth.sendTwoFactorEnabledEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/setup/email/verify') .send({ code: '123456' }); expect(response.status).toBe(200); expect(response.body.recoveryCodes).toBeDefined(); expect(mockUser.enableEmailTwoFactor).toHaveBeenCalled(); expect(mockUser.clearEmailOtp).toHaveBeenCalled(); }); it('should return 429 when OTP locked', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, isEmailOtpLocked: jest.fn().mockReturnValue(true), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/setup/email/verify') .send({ code: '123456' }); expect(response.status).toBe(429); expect(response.body.error).toContain('Too many failed attempts'); }); it('should return 400 for invalid OTP', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: false, isEmailOtpLocked: jest.fn().mockReturnValue(false), verifyEmailOtp: jest.fn().mockReturnValue(false), incrementEmailOtpAttempts: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/setup/email/verify') .send({ code: '123456' }); expect(response.status).toBe(400); expect(mockUser.incrementEmailOtpAttempts).toHaveBeenCalled(); }); }); // ============================================ // VERIFICATION ENDPOINTS // ============================================ describe('POST /2fa/verify/totp', () => { it('should verify TOTP code for step-up auth', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, twoFactorMethod: 'totp', verifyTotpCode: jest.fn().mockReturnValue(true), markTotpCodeUsed: jest.fn().mockResolvedValue(), updateStepUpSession: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/totp') .send({ code: '123456' }); expect(response.status).toBe(200); expect(response.body.verified).toBe(true); expect(mockUser.markTotpCodeUsed).toHaveBeenCalled(); expect(mockUser.updateStepUpSession).toHaveBeenCalled(); }); it('should return 400 when TOTP not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/verify/totp') .send({ code: '123456' }); expect(response.status).toBe(400); }); it('should return 400 when wrong 2FA method', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: true, twoFactorMethod: 'email', }); const response = await request(app) .post('/2fa/verify/totp') .send({ code: '123456' }); expect(response.status).toBe(400); }); it('should return 400 for invalid code', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, twoFactorMethod: 'totp', verifyTotpCode: jest.fn().mockReturnValue(false), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/totp') .send({ code: '123456' }); expect(response.status).toBe(400); }); }); describe('POST /2fa/verify/email/send', () => { it('should send email OTP for step-up auth', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, generateEmailOtp: jest.fn().mockResolvedValue('123456'), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendTwoFactorOtpEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/verify/email/send'); expect(response.status).toBe(200); expect(response.body.message).toContain('Verification code sent'); }); it('should return 400 when 2FA not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/verify/email/send'); expect(response.status).toBe(400); }); it('should return 500 when email fails', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, generateEmailOtp: jest.fn().mockResolvedValue('123456'), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendTwoFactorOtpEmail.mockRejectedValue(new Error('Email failed')); const response = await request(app) .post('/2fa/verify/email/send'); expect(response.status).toBe(500); }); }); describe('POST /2fa/verify/email', () => { it('should verify email OTP for step-up auth', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, isEmailOtpLocked: jest.fn().mockReturnValue(false), verifyEmailOtp: jest.fn().mockReturnValue(true), updateStepUpSession: jest.fn().mockResolvedValue(), clearEmailOtp: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/email') .send({ code: '123456' }); expect(response.status).toBe(200); expect(response.body.verified).toBe(true); }); it('should return 400 when 2FA not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/verify/email') .send({ code: '123456' }); expect(response.status).toBe(400); }); it('should return 429 when locked', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, isEmailOtpLocked: jest.fn().mockReturnValue(true), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/email') .send({ code: '123456' }); expect(response.status).toBe(429); }); it('should return 400 and increment attempts for invalid OTP', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, isEmailOtpLocked: jest.fn().mockReturnValue(false), verifyEmailOtp: jest.fn().mockReturnValue(false), incrementEmailOtpAttempts: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/email') .send({ code: '123456' }); expect(response.status).toBe(400); expect(mockUser.incrementEmailOtpAttempts).toHaveBeenCalled(); }); }); describe('POST /2fa/verify/recovery', () => { it('should verify recovery code', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, useRecoveryCode: jest.fn().mockResolvedValue({ valid: true, remainingCodes: 5 }), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendRecoveryCodeUsedEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/verify/recovery') .send({ code: 'XXXX-YYYY' }); expect(response.status).toBe(200); expect(response.body.verified).toBe(true); expect(response.body.remainingCodes).toBe(5); }); it('should warn when recovery codes are low', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, useRecoveryCode: jest.fn().mockResolvedValue({ valid: true, remainingCodes: 2 }), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendRecoveryCodeUsedEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/verify/recovery') .send({ code: 'XXXX-YYYY' }); expect(response.status).toBe(200); expect(response.body.warning).toContain('running low'); }); it('should return 400 when 2FA not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/verify/recovery') .send({ code: 'XXXX-YYYY' }); expect(response.status).toBe(400); }); it('should return 400 for invalid recovery code', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, useRecoveryCode: jest.fn().mockResolvedValue({ valid: false, remainingCodes: 0 }), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/verify/recovery') .send({ code: 'XXXX-YYYY' }); expect(response.status).toBe(400); }); }); // ============================================ // MANAGEMENT ENDPOINTS // ============================================ describe('GET /2fa/status', () => { it('should return 2FA status', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, twoFactorMethod: 'totp', getRemainingRecoveryCodes: jest.fn().mockReturnValue(5), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .get('/2fa/status'); expect(response.status).toBe(200); expect(response.body.enabled).toBe(true); expect(response.body.method).toBe('totp'); expect(response.body.hasRecoveryCodes).toBe(true); expect(response.body.lowRecoveryCodes).toBe(false); }); it('should return low recovery codes warning', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, twoFactorMethod: 'totp', getRemainingRecoveryCodes: jest.fn().mockReturnValue(1), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .get('/2fa/status'); expect(response.status).toBe(200); expect(response.body.lowRecoveryCodes).toBe(true); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .get('/2fa/status'); expect(response.status).toBe(404); }); }); describe('POST /2fa/disable', () => { it('should disable 2FA', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, disableTwoFactor: jest.fn().mockResolvedValue(), }; User.findByPk.mockResolvedValue(mockUser); emailServices.auth.sendTwoFactorDisabledEmail.mockResolvedValue(); const response = await request(app) .post('/2fa/disable'); expect(response.status).toBe(200); expect(response.body.message).toContain('disabled'); expect(mockUser.disableTwoFactor).toHaveBeenCalled(); }); it('should return 400 when 2FA not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/disable'); expect(response.status).toBe(400); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/2fa/disable'); expect(response.status).toBe(404); }); }); describe('POST /2fa/recovery/regenerate', () => { it('should regenerate recovery codes', async () => { const mockUser = { id: 'user-123', twoFactorEnabled: true, regenerateRecoveryCodes: jest.fn().mockResolvedValue(['NEW1-CODE', 'NEW2-CODE']), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .post('/2fa/recovery/regenerate'); expect(response.status).toBe(200); expect(response.body.recoveryCodes).toHaveLength(2); expect(response.body.warning).toContain('previous codes are now invalid'); }); it('should return 400 when 2FA not enabled', async () => { User.findByPk.mockResolvedValue({ id: 'user-123', twoFactorEnabled: false, }); const response = await request(app) .post('/2fa/recovery/regenerate'); expect(response.status).toBe(400); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .post('/2fa/recovery/regenerate'); expect(response.status).toBe(404); }); }); describe('GET /2fa/recovery/remaining', () => { it('should return recovery codes status', async () => { const mockUser = { id: 'user-123', getRemainingRecoveryCodes: jest.fn().mockReturnValue(8), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .get('/2fa/recovery/remaining'); expect(response.status).toBe(200); expect(response.body.hasRecoveryCodes).toBe(true); expect(response.body.lowRecoveryCodes).toBe(false); }); it('should indicate when low on recovery codes', async () => { const mockUser = { id: 'user-123', getRemainingRecoveryCodes: jest.fn().mockReturnValue(1), }; User.findByPk.mockResolvedValue(mockUser); const response = await request(app) .get('/2fa/recovery/remaining'); expect(response.status).toBe(200); expect(response.body.lowRecoveryCodes).toBe(true); }); it('should return 404 when user not found', async () => { User.findByPk.mockResolvedValue(null); const response = await request(app) .get('/2fa/recovery/remaining'); expect(response.status).toBe(404); }); }); });