501 lines
15 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
}); |