Files
rentall-app/backend/tests/unit/routes/twoFactor.test.js
2026-01-18 19:18:35 -05:00

794 lines
24 KiB
JavaScript

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