356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
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
|
|
});
|
|
});
|
|
});
|
|
});
|