const { enforceHTTPS, securityHeaders, addRequestId, logSecurityEvent, sanitizeError } = require('../../../middleware/security'); // Mock crypto module jest.mock('crypto', () => ({ randomBytes: jest.fn(() => ({ toString: jest.fn(() => 'mocked-hex-string-1234567890abcdef') })) })); describe('Security Middleware', () => { let req, res, next, consoleSpy, consoleWarnSpy, consoleErrorSpy; beforeEach(() => { req = { secure: false, headers: {}, protocol: 'http', url: '/test-path', ip: '127.0.0.1', connection: { remoteAddress: '127.0.0.1' }, get: jest.fn(), user: null }; res = { redirect: jest.fn(), setHeader: jest.fn(), status: jest.fn().mockReturnThis(), json: jest.fn() }; next = jest.fn(); // Mock console methods consoleSpy = jest.spyOn(console, 'log').mockImplementation(); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); jest.clearAllMocks(); }); afterEach(() => { consoleSpy.mockRestore(); consoleWarnSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); describe('enforceHTTPS', () => { describe('Development environment', () => { it('should skip HTTPS enforcement in dev environment', () => { process.env.NODE_ENV = 'dev'; enforceHTTPS(req, res, next); expect(next).toHaveBeenCalled(); expect(res.redirect).not.toHaveBeenCalled(); expect(res.setHeader).not.toHaveBeenCalled(); }); it('should skip HTTPS enforcement in development environment', () => { process.env.NODE_ENV = 'development'; enforceHTTPS(req, res, next); expect(next).toHaveBeenCalled(); expect(res.redirect).not.toHaveBeenCalled(); expect(res.setHeader).not.toHaveBeenCalled(); }); }); describe('Production environment', () => { beforeEach(() => { process.env.NODE_ENV = 'production'; process.env.FRONTEND_URL = 'example.com'; }); describe('HTTPS detection', () => { it('should detect HTTPS from req.secure', () => { req.secure = true; enforceHTTPS(req, res, next); expect(next).toHaveBeenCalled(); expect(res.redirect).not.toHaveBeenCalled(); expect(res.setHeader).toHaveBeenCalledWith( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); }); it('should detect HTTPS from x-forwarded-proto header', () => { req.headers['x-forwarded-proto'] = 'https'; enforceHTTPS(req, res, next); expect(next).toHaveBeenCalled(); expect(res.redirect).not.toHaveBeenCalled(); expect(res.setHeader).toHaveBeenCalledWith( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); }); it('should detect HTTPS from req.protocol', () => { req.protocol = 'https'; enforceHTTPS(req, res, next); expect(next).toHaveBeenCalled(); expect(res.redirect).not.toHaveBeenCalled(); expect(res.setHeader).toHaveBeenCalledWith( 'Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload' ); }); }); describe('HTTP to HTTPS redirect', () => { it('should redirect HTTP requests to HTTPS', () => { req.headers.host = 'example.com'; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); expect(next).not.toHaveBeenCalled(); }); it('should handle requests with query parameters', () => { req.url = '/test-path?param=value'; req.headers.host = 'example.com'; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path?param=value'); }); it('should log warning for host header mismatch', () => { req.headers.host = 'malicious.com'; req.ip = '192.168.1.1'; enforceHTTPS(req, res, next); expect(consoleWarnSpy).toHaveBeenCalledWith( '[SECURITY] Host header mismatch during HTTPS redirect:', { requestHost: 'malicious.com', allowedHost: 'example.com', ip: '192.168.1.1', url: '/test-path' } ); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); }); it('should not log warning when host matches allowed host', () => { req.headers.host = 'example.com'; enforceHTTPS(req, res, next); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); }); it('should use FRONTEND_URL as allowed host', () => { process.env.FRONTEND_URL = 'secure-site.com'; req.headers.host = 'different.com'; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://secure-site.com/test-path'); }); }); describe('Edge cases', () => { it('should handle missing host header', () => { delete req.headers.host; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/test-path'); }); it('should handle empty URL', () => { req.url = ''; req.headers.host = 'example.com'; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com'); }); it('should handle root path', () => { req.url = '/'; req.headers.host = 'example.com'; enforceHTTPS(req, res, next); expect(res.redirect).toHaveBeenCalledWith(301, 'https://example.com/'); }); }); }); }); describe('securityHeaders', () => { it('should set X-Content-Type-Options header', () => { securityHeaders(req, res, next); expect(res.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff'); }); it('should set X-Frame-Options header', () => { securityHeaders(req, res, next); expect(res.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY'); }); it('should set Referrer-Policy header', () => { securityHeaders(req, res, next); expect(res.setHeader).toHaveBeenCalledWith('Referrer-Policy', 'strict-origin-when-cross-origin'); }); it('should set Permissions-Policy header', () => { securityHeaders(req, res, next); expect(res.setHeader).toHaveBeenCalledWith( 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)' ); }); it('should call next after setting headers', () => { securityHeaders(req, res, next); expect(next).toHaveBeenCalled(); }); it('should set all security headers in one call', () => { securityHeaders(req, res, next); expect(res.setHeader).toHaveBeenCalledTimes(4); expect(res.setHeader).toHaveBeenNthCalledWith(1, 'X-Content-Type-Options', 'nosniff'); expect(res.setHeader).toHaveBeenNthCalledWith(2, 'X-Frame-Options', 'DENY'); expect(res.setHeader).toHaveBeenNthCalledWith(3, 'Referrer-Policy', 'strict-origin-when-cross-origin'); expect(res.setHeader).toHaveBeenNthCalledWith(4, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=(self)'); }); }); describe('addRequestId', () => { const crypto = require('crypto'); it('should generate and set request ID', () => { addRequestId(req, res, next); expect(crypto.randomBytes).toHaveBeenCalledWith(16); expect(req.id).toBe('mocked-hex-string-1234567890abcdef'); expect(res.setHeader).toHaveBeenCalledWith('X-Request-ID', 'mocked-hex-string-1234567890abcdef'); expect(next).toHaveBeenCalled(); }); it('should generate unique IDs for different requests', () => { const mockRandomBytes = require('crypto').randomBytes; // First call mockRandomBytes.mockReturnValueOnce({ toString: jest.fn(() => 'first-request-id') }); addRequestId(req, res, next); expect(req.id).toBe('first-request-id'); // Reset for second call jest.clearAllMocks(); const req2 = { ...req }; const res2 = { ...res, setHeader: jest.fn() }; const next2 = jest.fn(); // Second call mockRandomBytes.mockReturnValueOnce({ toString: jest.fn(() => 'second-request-id') }); addRequestId(req2, res2, next2); expect(req2.id).toBe('second-request-id'); }); it('should call toString with hex parameter', () => { const mockToString = jest.fn(() => 'hex-string'); require('crypto').randomBytes.mockReturnValueOnce({ toString: mockToString }); addRequestId(req, res, next); expect(mockToString).toHaveBeenCalledWith('hex'); }); }); describe('logSecurityEvent', () => { beforeEach(() => { req.id = 'test-request-id'; req.ip = '192.168.1.1'; req.get = jest.fn((header) => { if (header === 'user-agent') return 'Mozilla/5.0 Test Browser'; return null; }); }); describe('Production environment', () => { beforeEach(() => { process.env.NODE_ENV = 'production'; }); it('should log security event with JSON format', () => { const eventType = 'LOGIN_ATTEMPT'; const details = { username: 'testuser', success: false }; logSecurityEvent(eventType, details, req); expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', expect.any(String)); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData).toEqual({ timestamp: expect.any(String), eventType: 'LOGIN_ATTEMPT', requestId: 'test-request-id', ip: '192.168.1.1', userAgent: 'Mozilla/5.0 Test Browser', userId: 'anonymous', username: 'testuser', success: false }); }); it('should include user ID when user is authenticated', () => { req.user = { id: 123 }; const eventType = 'DATA_ACCESS'; const details = { resource: '/api/users' }; logSecurityEvent(eventType, details, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.userId).toBe(123); }); it('should handle missing request ID', () => { delete req.id; const eventType = 'SUSPICIOUS_ACTIVITY'; const details = { reason: 'Multiple failed attempts' }; logSecurityEvent(eventType, details, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.requestId).toBe('unknown'); }); it('should handle missing IP address', () => { delete req.ip; req.connection.remoteAddress = '10.0.0.1'; const eventType = 'IP_CHECK'; const details = { status: 'blocked' }; logSecurityEvent(eventType, details, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.ip).toBe('10.0.0.1'); }); it('should include ISO timestamp', () => { const eventType = 'TEST_EVENT'; const details = {}; logSecurityEvent(eventType, details, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); }); }); describe('Non-production environment', () => { beforeEach(() => { process.env.NODE_ENV = 'development'; }); it('should log security event with simple format', () => { const eventType = 'LOGIN_ATTEMPT'; const details = { username: 'testuser', success: false }; logSecurityEvent(eventType, details, req); expect(consoleSpy).toHaveBeenCalledWith( '[SECURITY]', 'LOGIN_ATTEMPT', { username: 'testuser', success: false } ); }); it('should not log JSON in development', () => { const eventType = 'TEST_EVENT'; const details = { test: true }; logSecurityEvent(eventType, details, req); expect(consoleSpy).toHaveBeenCalledWith('[SECURITY]', 'TEST_EVENT', { test: true }); // Ensure it's not JSON.stringify format expect(consoleSpy).not.toHaveBeenCalledWith('[SECURITY]', expect.stringMatching(/^{.*}$/)); }); }); describe('Edge cases', () => { it('should handle missing user-agent header', () => { req.get.mockReturnValue(null); process.env.NODE_ENV = 'production'; logSecurityEvent('TEST', {}, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.userAgent).toBeNull(); }); it('should handle empty details object', () => { process.env.NODE_ENV = 'production'; logSecurityEvent('EMPTY_DETAILS', {}, req); const loggedData = JSON.parse(consoleSpy.mock.calls[0][1]); expect(loggedData.eventType).toBe('EMPTY_DETAILS'); expect(Object.keys(loggedData)).toContain('timestamp'); }); }); }); describe('sanitizeError', () => { beforeEach(() => { req.id = 'test-request-id'; req.user = { id: 123 }; }); describe('Error logging', () => { it('should log full error details internally', () => { const error = new Error('Database connection failed'); error.stack = 'Error: Database connection failed\n at /app/db.js:10:5'; sanitizeError(error, req, res, next); expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', { requestId: 'test-request-id', error: 'Database connection failed', stack: 'Error: Database connection failed\n at /app/db.js:10:5', userId: 123 }); }); it('should handle missing user in logging', () => { req.user = null; const error = new Error('Test error'); sanitizeError(error, req, res, next); expect(consoleErrorSpy).toHaveBeenCalledWith('Error:', { requestId: 'test-request-id', error: 'Test error', stack: error.stack, userId: undefined }); }); }); describe('Client error responses (4xx)', () => { it('should handle 400 Bad Request errors', () => { const error = new Error('Invalid input data'); error.status = 400; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ error: 'Invalid input data', requestId: 'test-request-id' }); }); it('should handle 400 errors with default message', () => { const error = new Error(); error.status = 400; sanitizeError(error, req, res, next); expect(res.json).toHaveBeenCalledWith({ error: 'Bad Request', requestId: 'test-request-id' }); }); it('should handle 401 Unauthorized errors', () => { const error = new Error('Token expired'); error.status = 401; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(401); expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized', requestId: 'test-request-id' }); }); it('should handle 403 Forbidden errors', () => { const error = new Error('Access denied'); error.status = 403; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(403); expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden', requestId: 'test-request-id' }); }); it('should handle 404 Not Found errors', () => { const error = new Error('User not found'); error.status = 404; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(404); expect(res.json).toHaveBeenCalledWith({ error: 'Not Found', requestId: 'test-request-id' }); }); }); describe('Server error responses (5xx)', () => { describe('Development environment', () => { beforeEach(() => { process.env.NODE_ENV = 'development'; }); it('should include detailed error message and stack trace', () => { const error = new Error('Database connection failed'); error.status = 500; error.stack = 'Error: Database connection failed\n at /app/db.js:10:5'; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Database connection failed', requestId: 'test-request-id', stack: 'Error: Database connection failed\n at /app/db.js:10:5' }); }); it('should handle dev environment check', () => { process.env.NODE_ENV = 'dev'; const error = new Error('Test error'); error.status = 500; sanitizeError(error, req, res, next); expect(res.json).toHaveBeenCalledWith({ error: 'Test error', requestId: 'test-request-id', stack: error.stack }); }); it('should use default status 500 when not specified', () => { const error = new Error('Unhandled error'); sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Unhandled error', requestId: 'test-request-id', stack: error.stack }); }); it('should handle custom error status codes', () => { const error = new Error('Service unavailable'); error.status = 503; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(503); expect(res.json).toHaveBeenCalledWith({ error: 'Service unavailable', requestId: 'test-request-id', stack: error.stack }); }); }); describe('Production environment', () => { beforeEach(() => { process.env.NODE_ENV = 'production'; }); it('should return generic error message', () => { const error = new Error('Database connection failed'); error.status = 500; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error', requestId: 'test-request-id' }); }); it('should not include stack trace in production', () => { const error = new Error('Database error'); error.status = 500; error.stack = 'Error: Database error\n at /app/db.js:10:5'; sanitizeError(error, req, res, next); const response = res.json.mock.calls[0][0]; expect(response).not.toHaveProperty('stack'); }); it('should handle custom status codes in production', () => { const error = new Error('Service down'); error.status = 502; sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(502); expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error', requestId: 'test-request-id' }); }); it('should use default status 500 in production', () => { const error = new Error('Unknown error'); sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error', requestId: 'test-request-id' }); }); }); }); describe('Edge cases', () => { it('should handle error without message', () => { const error = new Error(); error.status = 400; sanitizeError(error, req, res, next); expect(res.json).toHaveBeenCalledWith({ error: 'Bad Request', requestId: 'test-request-id' }); }); it('should handle missing request ID', () => { delete req.id; const error = new Error('Test error'); error.status = 400; sanitizeError(error, req, res, next); expect(res.json).toHaveBeenCalledWith({ error: 'Test error', requestId: undefined }); }); it('should handle error without status property', () => { const error = new Error('No status error'); sanitizeError(error, req, res, next); expect(res.status).toHaveBeenCalledWith(500); }); it('should not call next() - error handling middleware', () => { const error = new Error('Test error'); sanitizeError(error, req, res, next); expect(next).not.toHaveBeenCalled(); }); }); }); describe('Module exports', () => { it('should export all required functions', () => { const securityModule = require('../../../middleware/security'); expect(securityModule).toHaveProperty('enforceHTTPS'); expect(securityModule).toHaveProperty('securityHeaders'); expect(securityModule).toHaveProperty('addRequestId'); expect(securityModule).toHaveProperty('logSecurityEvent'); expect(securityModule).toHaveProperty('sanitizeError'); }); it('should export functions with correct types', () => { const securityModule = require('../../../middleware/security'); expect(typeof securityModule.enforceHTTPS).toBe('function'); expect(typeof securityModule.securityHeaders).toBe('function'); expect(typeof securityModule.addRequestId).toBe('function'); expect(typeof securityModule.logSecurityEvent).toBe('function'); expect(typeof securityModule.sanitizeError).toBe('function'); }); }); });