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

501 lines
15 KiB
JavaScript

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