backend unit tests
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user