const { requireStepUpAuth } = require('../../../middleware/stepUpAuth'); // Mock TwoFactorService jest.mock('../../../services/TwoFactorService', () => ({ validateStepUpSession: jest.fn(), getRemainingRecoveryCodesCount: jest.fn() })); // Mock logger jest.mock('../../../utils/logger', () => ({ info: jest.fn(), error: jest.fn() })); const TwoFactorService = require('../../../services/TwoFactorService'); const logger = require('../../../utils/logger'); describe('stepUpAuth Middleware', () => { let req, res, next; beforeEach(() => { req = { user: { id: 1, email: 'test@test.com', twoFactorEnabled: true, twoFactorMethod: 'totp', recoveryCodesHash: null } }; res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; next = jest.fn(); jest.clearAllMocks(); }); describe('requireStepUpAuth', () => { describe('2FA Disabled Path', () => { it('should call next() when user has 2FA disabled', async () => { req.user.twoFactorEnabled = false; const middleware = requireStepUpAuth('sensitive_action'); await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(TwoFactorService.validateStepUpSession).not.toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); it('should call next() when twoFactorEnabled is null/falsy', async () => { req.user.twoFactorEnabled = null; const middleware = requireStepUpAuth('sensitive_action'); await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); it('should call next() when twoFactorEnabled is undefined', async () => { delete req.user.twoFactorEnabled; const middleware = requireStepUpAuth('sensitive_action'); await middleware(req, res, next); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); }); }); describe('Valid Step-Up Session', () => { it('should call next() when validateStepUpSession returns true', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(true); const middleware = requireStepUpAuth('password_change'); await middleware(req, res, next); expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user); expect(next).toHaveBeenCalled(); expect(res.status).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); }); it('should validate step-up session for different actions', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(true); const actions = ['password_change', 'delete_account', 'change_email', 'export_data']; for (const action of actions) { jest.clearAllMocks(); const middleware = requireStepUpAuth(action); await middleware(req, res, next); expect(TwoFactorService.validateStepUpSession).toHaveBeenCalledWith(req.user); expect(next).toHaveBeenCalled(); } }); }); describe('Invalid/Expired Session', () => { it('should return 403 with STEP_UP_REQUIRED code when session is invalid', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); const middleware = requireStepUpAuth('password_change'); await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Multi-factor authentication required', code: 'STEP_UP_REQUIRED', action: 'password_change' })); expect(next).not.toHaveBeenCalled(); }); it('should include action name in response', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); const actionName = 'delete_account'; const middleware = requireStepUpAuth(actionName); await middleware(req, res, next); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ action: actionName })); }); it('should log step-up requirement with user ID and action', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); const middleware = requireStepUpAuth('export_data'); await middleware(req, res, next); expect(logger.info).toHaveBeenCalledWith( `Step-up authentication required for user ${req.user.id}, action: export_data` ); }); it('should include methods array in response', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); const middleware = requireStepUpAuth('password_change'); await middleware(req, res, next); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ methods: expect.any(Array) })); }); }); describe('Available Methods Logic', () => { beforeEach(() => { TwoFactorService.validateStepUpSession.mockReturnValue(false); }); it('should return totp and email for TOTP users', async () => { req.user.twoFactorMethod = 'totp'; req.user.recoveryCodesHash = null; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).toContain('totp'); expect(response.methods).toContain('email'); }); it('should return email only for email-2FA users', async () => { req.user.twoFactorMethod = 'email'; req.user.recoveryCodesHash = null; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).not.toContain('totp'); expect(response.methods).toContain('email'); }); it('should include recovery when recovery codes remain (count > 0)', async () => { req.user.twoFactorMethod = 'totp'; req.user.recoveryCodesHash = JSON.stringify({ codes: ['code1', 'code2'] }); TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).toContain('recovery'); expect(TwoFactorService.getRemainingRecoveryCodesCount).toHaveBeenCalled(); }); it('should exclude recovery when all codes used (count = 0)', async () => { req.user.twoFactorMethod = 'totp'; req.user.recoveryCodesHash = JSON.stringify({ codes: [] }); TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(0); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).not.toContain('recovery'); }); it('should exclude recovery when recoveryCodesHash is null', async () => { req.user.twoFactorMethod = 'totp'; req.user.recoveryCodesHash = null; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).not.toContain('recovery'); expect(TwoFactorService.getRemainingRecoveryCodesCount).not.toHaveBeenCalled(); }); it('should return correct methods for TOTP user with recovery codes', async () => { req.user.twoFactorMethod = 'totp'; req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2', 'ghi3-jkl4'] }); TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(2); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).toEqual(['totp', 'email', 'recovery']); }); it('should return correct methods for email-2FA user with recovery codes', async () => { req.user.twoFactorMethod = 'email'; req.user.recoveryCodesHash = JSON.stringify({ codes: ['abc1-def2'] }); TwoFactorService.getRemainingRecoveryCodesCount.mockReturnValue(1); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).toEqual(['email', 'recovery']); }); }); describe('Error Handling', () => { it('should return 500 when TwoFactorService.validateStepUpSession throws', async () => { TwoFactorService.validateStepUpSession.mockImplementation(() => { throw new Error('Service error'); }); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'An error occurred during authentication' }); expect(next).not.toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith( 'Step-up auth middleware error:', expect.any(Error) ); }); it('should return 500 when TwoFactorService.getRemainingRecoveryCodesCount throws', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); req.user.recoveryCodesHash = JSON.stringify({ codes: [] }); TwoFactorService.getRemainingRecoveryCodesCount.mockImplementation(() => { throw new Error('Service error'); }); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'An error occurred during authentication' }); expect(logger.error).toHaveBeenCalled(); }); it('should handle malformed recoveryCodesHash JSON', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); req.user.recoveryCodesHash = 'invalid json {{{'; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'An error occurred during authentication' }); expect(logger.error).toHaveBeenCalled(); }); it('should log error details when exception occurs', async () => { const testError = new Error('Test error message'); TwoFactorService.validateStepUpSession.mockImplementation(() => { throw testError; }); const middleware = requireStepUpAuth('action'); await middleware(req, res, next); expect(logger.error).toHaveBeenCalledWith( 'Step-up auth middleware error:', testError ); }); }); describe('Edge Cases', () => { it('should handle user with empty string twoFactorMethod', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); req.user.twoFactorMethod = ''; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); const response = res.json.mock.calls[0][0]; expect(response.methods).toContain('email'); expect(response.methods).not.toContain('totp'); }); it('should handle action parameter as empty string', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); const middleware = requireStepUpAuth(''); await middleware(req, res, next); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ action: '' })); }); it('should handle various user ID types', async () => { TwoFactorService.validateStepUpSession.mockReturnValue(false); // Test with string ID req.user.id = 'user-uuid-123'; const middleware = requireStepUpAuth('action'); await middleware(req, res, next); expect(logger.info).toHaveBeenCalledWith( 'Step-up authentication required for user user-uuid-123, action: action' ); }); it('should be a factory function that returns middleware', () => { const middleware = requireStepUpAuth('test_action'); expect(typeof middleware).toBe('function'); expect(middleware.length).toBe(3); // Should accept req, res, next }); }); }); });