Files
rentall-app/backend/tests/unit/middleware/security.test.js
2025-09-19 19:46:41 -04:00

723 lines
22 KiB
JavaScript

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