backend unit tests
This commit is contained in:
194
backend/tests/unit/middleware/auth.test.js
Normal file
194
backend/tests/unit/middleware/auth.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
const { authenticateToken } = require('../../../middleware/auth');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
jest.mock('jsonwebtoken');
|
||||
jest.mock('../../../models', () => ({
|
||||
User: {
|
||||
findByPk: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
const { User } = require('../../../models');
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
cookies: {}
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
});
|
||||
|
||||
describe('Valid token', () => {
|
||||
it('should verify valid token from cookie and call next', async () => {
|
||||
const mockUser = { id: 1, email: 'test@test.com' };
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 1 });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('validtoken', process.env.JWT_SECRET);
|
||||
expect(User.findByPk).toHaveBeenCalledWith(1);
|
||||
expect(req.user).toEqual(mockUser);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token with valid user', async () => {
|
||||
const mockUser = { id: 2, email: 'user@test.com', firstName: 'Test' };
|
||||
req.cookies.accessToken = 'validtoken2';
|
||||
jwt.verify.mockReturnValue({ id: 2 });
|
||||
User.findByPk.mockResolvedValue(mockUser);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith('validtoken2', process.env.JWT_SECRET);
|
||||
expect(User.findByPk).toHaveBeenCalledWith(2);
|
||||
expect(req.user).toEqual(mockUser);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid token', () => {
|
||||
it('should return 401 for missing token', async () => {
|
||||
req.cookies = {};
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for invalid token', async () => {
|
||||
req.cookies.accessToken = 'invalidtoken';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for expired token', async () => {
|
||||
req.cookies.accessToken = 'expiredtoken';
|
||||
const error = new Error('jwt expired');
|
||||
error.name = 'TokenExpiredError';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Token expired',
|
||||
code: 'TOKEN_EXPIRED'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 for invalid token format (missing user id)', async () => {
|
||||
req.cookies.accessToken = 'tokenwithnoid';
|
||||
jwt.verify.mockReturnValue({ email: 'test@test.com' }); // Missing id
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token format',
|
||||
code: 'INVALID_TOKEN_FORMAT'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when user not found', async () => {
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 999 });
|
||||
User.findByPk.mockResolvedValue(null);
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'User not found',
|
||||
code: 'USER_NOT_FOUND'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string token', async () => {
|
||||
req.cookies.accessToken = '';
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JWT malformed error', async () => {
|
||||
req.cookies.accessToken = 'malformed.token';
|
||||
const error = new Error('jwt malformed');
|
||||
error.name = 'JsonWebTokenError';
|
||||
jwt.verify.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle database error when finding user', async () => {
|
||||
req.cookies.accessToken = 'validtoken';
|
||||
jwt.verify.mockReturnValue({ id: 1 });
|
||||
User.findByPk.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined cookies', async () => {
|
||||
req.cookies = undefined;
|
||||
|
||||
await authenticateToken(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Access token required',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
506
backend/tests/unit/middleware/csrf.test.js
Normal file
506
backend/tests/unit/middleware/csrf.test.js
Normal file
@@ -0,0 +1,506 @@
|
||||
const mockTokensInstance = {
|
||||
secretSync: jest.fn().mockReturnValue('mock-secret'),
|
||||
create: jest.fn().mockReturnValue('mock-token-123'),
|
||||
verify: jest.fn().mockReturnValue(true)
|
||||
};
|
||||
|
||||
jest.mock('csrf', () => {
|
||||
return jest.fn().mockImplementation(() => mockTokensInstance);
|
||||
});
|
||||
|
||||
jest.mock('cookie-parser', () => {
|
||||
return jest.fn().mockReturnValue((req, res, next) => next());
|
||||
});
|
||||
|
||||
const { csrfProtection, generateCSRFToken, getCSRFToken } = require('../../../middleware/csrf');
|
||||
|
||||
describe('CSRF Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
method: 'POST',
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
cookies: {}
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
set: jest.fn(),
|
||||
locals: {}
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('csrfProtection', () => {
|
||||
describe('Safe methods', () => {
|
||||
it('should skip CSRF protection for GET requests', () => {
|
||||
req.method = 'GET';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip CSRF protection for HEAD requests', () => {
|
||||
req.method = 'HEAD';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip CSRF protection for OPTIONS requests', () => {
|
||||
req.method = 'OPTIONS';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token validation', () => {
|
||||
beforeEach(() => {
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
});
|
||||
|
||||
it('should validate token from x-csrf-token header', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate token from request body', () => {
|
||||
req.body.csrfToken = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate token from query parameters', () => {
|
||||
req.query.csrfToken = 'mock-token-123';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer header token over body token', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.body.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer header token over query token', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.query.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer body token over query token', () => {
|
||||
req.body.csrfToken = 'mock-token-123';
|
||||
req.query.csrfToken = 'different-token';
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing tokens', () => {
|
||||
it('should return 403 when no token provided', () => {
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when no cookie token provided', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = {};
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when cookies object is missing', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = undefined;
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when both tokens are missing', () => {
|
||||
req.cookies = {};
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token mismatch', () => {
|
||||
it('should return 403 when tokens do not match', () => {
|
||||
req.headers['x-csrf-token'] = 'token-from-header';
|
||||
req.cookies = { 'csrf-token': 'token-from-cookie' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when header token is empty but cookie exists', () => {
|
||||
req.headers['x-csrf-token'] = '';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when cookie token is empty but header exists', () => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': '' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_MISMATCH'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token verification', () => {
|
||||
beforeEach(() => {
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
});
|
||||
|
||||
it('should return 403 when token verification fails', () => {
|
||||
mockTokensInstance.verify.mockReturnValue(false);
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Invalid CSRF token',
|
||||
code: 'CSRF_TOKEN_INVALID'
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call next when token verification succeeds', () => {
|
||||
mockTokensInstance.verify.mockReturnValue(true);
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle case-insensitive HTTP methods', () => {
|
||||
req.method = 'post';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PUT requests', () => {
|
||||
req.method = 'PUT';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle DELETE requests', () => {
|
||||
req.method = 'DELETE';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PATCH requests', () => {
|
||||
req.method = 'PATCH';
|
||||
req.headers['x-csrf-token'] = 'mock-token-123';
|
||||
req.cookies = { 'csrf-token': 'mock-token-123' };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.verify).toHaveBeenCalledWith('mock-secret', 'mock-token-123');
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCSRFToken', () => {
|
||||
it('should generate token and set cookie with proper options', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set secure flag to false in dev environment', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to true in non-dev environment', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set token in response header', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith('X-CSRF-Token', 'mock-token-123');
|
||||
});
|
||||
|
||||
it('should make token available in res.locals', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.locals.csrfToken).toBe('mock-token-123');
|
||||
});
|
||||
|
||||
it('should call next after setting up token', () => {
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle test environment', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined NODE_ENV', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
generateCSRFToken(req, res, next);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCSRFToken', () => {
|
||||
it('should generate token and return it in response', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(mockTokensInstance.create).toHaveBeenCalledWith('mock-secret');
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'mock-token-123' });
|
||||
});
|
||||
|
||||
it('should set token in cookie with proper options', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to false in dev environment', () => {
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should set secure flag to true in production environment', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle test environment', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
getCSRFToken(req, res);
|
||||
|
||||
expect(res.cookie).toHaveBeenCalledWith('csrf-token', 'mock-token-123', {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 1000
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate new token each time', () => {
|
||||
mockTokensInstance.create
|
||||
.mockReturnValueOnce('token-1')
|
||||
.mockReturnValueOnce('token-2');
|
||||
|
||||
getCSRFToken(req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-1' });
|
||||
|
||||
getCSRFToken(req, res);
|
||||
expect(res.json).toHaveBeenCalledWith({ csrfToken: 'token-2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle complete CSRF flow', () => {
|
||||
// First, generate a token
|
||||
generateCSRFToken(req, res, next);
|
||||
const generatedToken = res.locals.csrfToken;
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Now test protection with the generated token
|
||||
req.method = 'POST';
|
||||
req.headers['x-csrf-token'] = generatedToken;
|
||||
req.cookies = { 'csrf-token': generatedToken };
|
||||
|
||||
csrfProtection(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token generation endpoint flow', () => {
|
||||
getCSRFToken(req, res);
|
||||
|
||||
const tokenFromResponse = res.json.mock.calls[0][0].csrfToken;
|
||||
const cookieCall = res.cookie.mock.calls[0];
|
||||
|
||||
expect(cookieCall[0]).toBe('csrf-token');
|
||||
expect(cookieCall[1]).toBe(tokenFromResponse);
|
||||
expect(tokenFromResponse).toBe('mock-token-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
501
backend/tests/unit/middleware/rateLimiter.test.js
Normal file
@@ -0,0 +1,501 @@
|
||||
// Mock express-rate-limit
|
||||
const mockRateLimitInstance = jest.fn();
|
||||
|
||||
jest.mock('express-rate-limit', () => {
|
||||
const rateLimitFn = jest.fn((config) => {
|
||||
// Store the config for inspection in tests
|
||||
rateLimitFn.lastConfig = config;
|
||||
return mockRateLimitInstance;
|
||||
});
|
||||
rateLimitFn.defaultKeyGenerator = jest.fn().mockReturnValue('127.0.0.1');
|
||||
return rateLimitFn;
|
||||
});
|
||||
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const {
|
||||
placesAutocomplete,
|
||||
placeDetails,
|
||||
geocoding,
|
||||
loginLimiter,
|
||||
registerLimiter,
|
||||
passwordResetLimiter,
|
||||
generalLimiter,
|
||||
burstProtection,
|
||||
createMapsRateLimiter,
|
||||
createUserBasedRateLimiter
|
||||
} = require('../../../middleware/rateLimiter');
|
||||
|
||||
describe('Rate Limiter Middleware', () => {
|
||||
let req, res, next;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
ip: '127.0.0.1',
|
||||
user: null
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
set: jest.fn()
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createMapsRateLimiter', () => {
|
||||
it('should create rate limiter with correct configuration', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
windowMs,
|
||||
max,
|
||||
message: {
|
||||
error: message,
|
||||
retryAfter: Math.ceil(windowMs / 1000)
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: expect.any(Function)
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyGenerator', () => {
|
||||
it('should use user ID when user is authenticated', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithUser = { user: { id: 123 } };
|
||||
const key = config.keyGenerator(reqWithUser);
|
||||
|
||||
expect(key).toBe('user:123');
|
||||
});
|
||||
|
||||
it('should use default IP generator when user is not authenticated', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithoutUser = { user: null };
|
||||
config.keyGenerator(reqWithoutUser);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithoutUser);
|
||||
});
|
||||
|
||||
it('should use default IP generator when user has no ID', () => {
|
||||
const windowMs = 60000;
|
||||
const max = 30;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
const config = rateLimit.lastConfig;
|
||||
|
||||
const reqWithUserNoId = { user: {} };
|
||||
config.keyGenerator(reqWithUserNoId);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(reqWithUserNoId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate retryAfter correctly', () => {
|
||||
const windowMs = 90000; // 90 seconds
|
||||
const max = 10;
|
||||
const message = 'Test message';
|
||||
|
||||
createMapsRateLimiter(windowMs, max, message);
|
||||
|
||||
expect(rateLimit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: {
|
||||
error: message,
|
||||
retryAfter: 90 // Math.ceil(90000 / 1000)
|
||||
}
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pre-configured rate limiters', () => {
|
||||
describe('placesAutocomplete', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof placesAutocomplete).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeDetails', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof placeDetails).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('geocoding', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof geocoding).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof loginLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof registerLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('passwordResetLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof passwordResetLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generalLimiter', () => {
|
||||
it('should be a function (rate limiter middleware)', () => {
|
||||
expect(typeof generalLimiter).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserBasedRateLimiter', () => {
|
||||
let userBasedLimiter;
|
||||
const windowMs = 10000; // 10 seconds
|
||||
const max = 5;
|
||||
const message = 'Too many requests';
|
||||
|
||||
beforeEach(() => {
|
||||
userBasedLimiter = createUserBasedRateLimiter(windowMs, max, message);
|
||||
});
|
||||
|
||||
describe('Key generation', () => {
|
||||
it('should use user ID when user is authenticated', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use IP when user is not authenticated', () => {
|
||||
req.user = null;
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate limiting logic', () => {
|
||||
it('should allow requests within limit', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Make requests within limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests when limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: message,
|
||||
retryAfter: expect.any(Number)
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set correct rate limit headers', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(res.set).toHaveBeenCalledWith({
|
||||
'RateLimit-Limit': max,
|
||||
'RateLimit-Remaining': max - 1,
|
||||
'RateLimit-Reset': expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should update remaining count correctly', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// First request
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'RateLimit-Remaining': 4
|
||||
}));
|
||||
|
||||
// Second request
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.set).toHaveBeenCalledWith(expect.objectContaining({
|
||||
'RateLimit-Remaining': 3
|
||||
}));
|
||||
});
|
||||
|
||||
it('should not go below 0 for remaining count', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Check that remaining doesn't go negative
|
||||
const lastCall = res.set.mock.calls[res.set.mock.calls.length - 1][0];
|
||||
expect(lastCall['RateLimit-Remaining']).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window management', () => {
|
||||
it('should reset count after window expires', () => {
|
||||
req.user = { id: 123 };
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
// Mock time to start of window
|
||||
let currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// Verify limit is reached
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// Move time forward past the window
|
||||
currentTime += windowMs + 1000;
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Should allow requests again
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
|
||||
it('should clean up old entries from store', () => {
|
||||
const originalDateNow = Date.now;
|
||||
let currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Create entries for different users
|
||||
req.user = { id: 1 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
req.user = { id: 2 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
// Move time forward to expire first entries
|
||||
currentTime += windowMs + 1000;
|
||||
|
||||
req.user = { id: 3 };
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
// The cleanup should have occurred when processing user 3's request
|
||||
// We can't directly test the internal store, but we can verify the behavior
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Different users/IPs', () => {
|
||||
it('should maintain separate counts for different users', () => {
|
||||
// User 1 makes max requests
|
||||
req.user = { id: 1 };
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// User 1 should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// User 2 should still be allowed
|
||||
jest.clearAllMocks();
|
||||
req.user = { id: 2 };
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should maintain separate counts for different IPs', () => {
|
||||
req.user = null;
|
||||
|
||||
// IP 1 makes max requests
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.1');
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
// IP 1 should be blocked
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
|
||||
// IP 2 should still be allowed
|
||||
jest.clearAllMocks();
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('192.168.1.2');
|
||||
userBasedLimiter(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle undefined user gracefully', () => {
|
||||
req.user = undefined;
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle user object without id', () => {
|
||||
req.user = { email: 'test@test.com' };
|
||||
rateLimit.defaultKeyGenerator.mockReturnValue('127.0.0.1');
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
expect(rateLimit.defaultKeyGenerator).toHaveBeenCalledWith(req);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set correct reset time in ISO format', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
const setCall = res.set.mock.calls[0][0];
|
||||
const resetTime = setCall['RateLimit-Reset'];
|
||||
|
||||
// Should be a valid ISO string
|
||||
expect(() => new Date(resetTime)).not.toThrow();
|
||||
expect(resetTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should calculate retry after correctly when limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
const originalDateNow = Date.now;
|
||||
const currentTime = 1000000000;
|
||||
Date.now = jest.fn(() => currentTime);
|
||||
|
||||
// Exhaust the limit
|
||||
for (let i = 0; i < max; i++) {
|
||||
userBasedLimiter(req, res, next);
|
||||
}
|
||||
|
||||
jest.clearAllMocks();
|
||||
userBasedLimiter(req, res, next);
|
||||
|
||||
const jsonCall = res.json.mock.calls[0][0];
|
||||
expect(jsonCall.retryAfter).toBe(Math.ceil(windowMs / 1000));
|
||||
|
||||
// Restore original Date.now
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('burstProtection', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof burstProtection).toBe('function');
|
||||
});
|
||||
|
||||
it('should allow requests within burst limit', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Should allow up to 5 requests in 10 seconds
|
||||
for (let i = 0; i < 5; i++) {
|
||||
jest.clearAllMocks();
|
||||
burstProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block requests when burst limit exceeded', () => {
|
||||
req.user = { id: 123 };
|
||||
|
||||
// Exhaust burst limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
burstProtection(req, res, next);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
jest.clearAllMocks();
|
||||
burstProtection(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Too many requests in a short period. Please slow down.',
|
||||
retryAfter: expect.any(Number)
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Module exports', () => {
|
||||
it('should export all required rate limiters', () => {
|
||||
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||
|
||||
expect(rateLimiterModule).toHaveProperty('placesAutocomplete');
|
||||
expect(rateLimiterModule).toHaveProperty('placeDetails');
|
||||
expect(rateLimiterModule).toHaveProperty('geocoding');
|
||||
expect(rateLimiterModule).toHaveProperty('loginLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('registerLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('passwordResetLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('generalLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('burstProtection');
|
||||
expect(rateLimiterModule).toHaveProperty('createMapsRateLimiter');
|
||||
expect(rateLimiterModule).toHaveProperty('createUserBasedRateLimiter');
|
||||
});
|
||||
|
||||
it('should export functions for utility methods', () => {
|
||||
const rateLimiterModule = require('../../../middleware/rateLimiter');
|
||||
|
||||
expect(typeof rateLimiterModule.createMapsRateLimiter).toBe('function');
|
||||
expect(typeof rateLimiterModule.createUserBasedRateLimiter).toBe('function');
|
||||
expect(typeof rateLimiterModule.burstProtection).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
723
backend/tests/unit/middleware/security.test.js
Normal file
723
backend/tests/unit/middleware/security.test.js
Normal file
@@ -0,0 +1,723 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
2061
backend/tests/unit/middleware/validation.test.js
Normal file
2061
backend/tests/unit/middleware/validation.test.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user