import { jest } from '@jest/globals' import { APIGatewayProxyEvent, Context } from 'aws-lambda' import { expect as c_expect } from 'chai' import request from 'supertest' import { HttpMethod } from '../../../../src/API/Request.js' import Redis from '../../../../src/Cache/Redis.js' import Globals from '../../../../src/Globals.js' import Proxy from '../../../../src/Server/lib/container/Proxy.js' import { GlobalRateLimitConfig, RateLimitConfig } from '../../../../src/Server/Router.js' import { defaultUrl } from '../../../Test.utils.js' describe('Rate Limiting', () => { // @ts-ignore let mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}) let proxy: Proxy | null = null let redisConnectionSpy: jest.SpiedFunction | null = null const redisCacheConfig = { type: 'redis' as const, hostname: 'localhost', username: 'default', enableTLS: false, } beforeAll(() => { // @ts-ignore mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}) }) afterAll(() => { mockExit.mockRestore() }) beforeEach(() => { mockExit.mockReset() }) afterEach(async () => { if (redisConnectionSpy) { redisConnectionSpy.mockRestore() redisConnectionSpy = null } if (proxy) { await proxy.unload() proxy = null } // Give ports time to release await new Promise(resolve => setTimeout(resolve, 100)) }) test('Rate limit is applied with default settings', async () => { const port = 57001 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, // 1 second window for testing limit: 3, // Allow only 3 requests } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // First 3 requests should succeed for (let i = 0; i < 3; i++) { const res = await request(url).get('/test').expect(200) c_expect(res.body).to.deep.equal({ success: true }) } // 4th request should be rate limited const rateLimitedRes = await request(url).get('/test').expect(429) c_expect(rateLimitedRes.body).to.have.property('error', 'rate_limit_exceeded') c_expect(rateLimitedRes.body).to.have.property('message') // Wait for window to reset await new Promise(resolve => setTimeout(resolve, 1100)) // Should work again after window reset const afterResetRes = await request(url).get('/test').expect(200) c_expect(afterResetRes.body).to.deep.equal({ success: true }) }, 10000) test('Rate limit headers are present', async () => { const port = 57002 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 60000, limit: 10, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() const res = await request(url).get('/test').expect(200) // Check for rate limit headers c_expect(res.headers).to.have.property('ratelimit-limit') c_expect(res.headers).to.have.property('ratelimit-remaining') c_expect(res.headers).to.have.property('ratelimit-reset') // Verify header values c_expect(res.headers['ratelimit-limit']).to.equal('10') c_expect(parseInt(res.headers['ratelimit-remaining'])).to.be.at.most(9) }) test('Rate limit can be disabled', async () => { const port = 57003 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: false, windowMs: 100, limit: 2, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Should be able to make many requests without hitting limit for (let i = 0; i < 5; i++) { const res = await request(url).get('/test').expect(200) c_expect(res.body).to.deep.equal({ success: true }) } }) test('Rate limit with custom handler', async () => { const port = 57004 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) let customHandlerCalled = false const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, handler: (req, res) => { customHandlerCalled = true res.status(429).json({ custom: true, message: 'Custom rate limit message', }) }, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // First 2 requests succeed await request(url).get('/test').expect(200) await request(url).get('/test').expect(200) // 3rd request hits custom handler const rateLimitedRes = await request(url).get('/test').expect(429) c_expect(customHandlerCalled).to.be.true c_expect(rateLimitedRes.body).to.have.property('custom', true) c_expect(rateLimitedRes.body).to.have.property('message', 'Custom rate limit message') }, 10000) test('Rate limit with skip function', async () => { const port = 57005 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, skip: req => { // Skip rate limiting for requests with special header return req.headers['x-skip-rate-limit'] === 'true' }, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Use up the rate limit await request(url).get('/test').expect(200) await request(url).get('/test').expect(200) // Normal request should be rate limited await request(url).get('/test').expect(429) // Request with skip header should succeed const skippedRes = await request(url).get('/test').set('x-skip-rate-limit', 'true').expect(200) c_expect(skippedRes.body).to.deep.equal({ success: true }) }, 10000) test('Rate limit with custom key generator', async () => { const port = 57006 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, keyGenerator: req => { // Use custom header for rate limiting key instead of IP return (req.headers['x-user-id'] as string) || 'anonymous' }, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // User 1 makes 2 requests (uses up their limit) await request(url).get('/test').set('x-user-id', 'user1').expect(200) await request(url).get('/test').set('x-user-id', 'user1').expect(200) // User 1's 3rd request should be rate limited await request(url).get('/test').set('x-user-id', 'user1').expect(429) // User 2 should still have their own limit const user2Res = await request(url).get('/test').set('x-user-id', 'user2').expect(200) c_expect(user2Res.body).to.deep.equal({ success: true }) }, 10000) test('Rate limit without config does not apply limiting', async () => { const port = 57007 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) proxy = new Proxy( { routes: [], port, // No rateLimit config }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Should be able to make many requests for (let i = 0; i < 10; i++) { const res = await request(url).get('/test').expect(200) c_expect(res.body).to.deep.equal({ success: true }) } }) test('Multiple requests from different IPs have separate limits', async () => { const port = 57008 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 1, // Very strict: only 1 request per window keyGenerator: req => { // Use x-forwarded-for header to simulate different IPs return (req.headers['x-forwarded-for'] as string) || req.ip || 'unknown' }, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // IP1 makes a request (uses their limit) await request(url).get('/test').set('x-forwarded-for', '192.168.1.1').expect(200) // IP1's second request should be rate limited await request(url).get('/test').set('x-forwarded-for', '192.168.1.1').expect(429) // IP2 should still have their own limit available const ip2Res = await request(url).get('/test').set('x-forwarded-for', '192.168.1.2').expect(200) c_expect(ip2Res.body).to.deep.equal({ success: true }) // IP2's second request should also be rate limited await request(url).get('/test').set('x-forwarded-for', '192.168.1.2').expect(429) }, 10000) test('Rate limit with containerSetupHook', async () => { const port = 57009 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) let hookCalled = false const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 5, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, containerSetupHook: async (listener, app) => { hookCalled = true // Hook can be used to add custom middleware or configure the server c_expect(listener).to.exist c_expect(app).to.exist }, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Verify hook was called c_expect(hookCalled).to.be.true // Verify rate limiting still works after hook const res = await request(url).get('/test').expect(200) c_expect(res.body).to.deep.equal({ success: true }) // Check rate limit headers are present c_expect(res.headers).to.have.property('ratelimit-limit') }) test('Rate limit with Redis store configuration', async () => { const port = 57010 // Mock Redis client with proper typing const mockRedisClient: any = { sendCommand: jest.fn(async () => 'OK'), connect: jest.fn(async () => undefined), disconnect: jest.fn(async () => undefined), isOpen: true, isReady: true, } const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: { prefix: 'test:rl:', }, } redisConnectionSpy = jest.spyOn(Redis, 'connection').mockResolvedValue(mockRedisClient) proxy = new Proxy( { routes: [], port, cache: redisCacheConfig, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() c_expect(redisConnectionSpy?.mock.calls[0]?.[0]).to.deep.equal(redisCacheConfig) // Verify the proxy loaded successfully with Redis config (covers lines 248-252) // The fact that it didn't throw an error means Redis store was created correctly c_expect(proxy).to.exist }) test('Rate limit Redis store requires WAPI cache configuration', async () => { const failingProxy = new Proxy( { routes: [], port: 57018, rateLimit: { enabled: true, store: 'redis', }, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await expect(failingProxy.load()).rejects.toThrow( 'RouterConfig.cache is required when rateLimit.store is set to redis' ) }) test('Rate limit logs indicate store type', async () => { const port = 57011 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 5, store: 'memory', // Explicitly set to memory } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Make requests to verify in-memory store works (covers line 265) const res = await request(url).get('/test').expect(200) c_expect(res.body).to.deep.equal({ success: true }) // Verify rate limiting is actually working for (let i = 0; i < 4; i++) { await request(url).get('/test').expect(200) } // 6th request should be rate limited (limit is 5) await request(url).get('/test').expect(429) }) test('Rate limit uses default keyGenerator with IP fallback chain', async () => { const port = 57012 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) // Don't provide a custom keyGenerator - should use default const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, // NO keyGenerator provided - will use default IP-based logic } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Make requests without x-forwarded-for header // Should use req.ip or req.socket.remoteAddress const res1 = await request(url).get('/test').expect(200) c_expect(res1.body).to.deep.equal({ success: true }) const res2 = await request(url).get('/test').expect(200) c_expect(res2.body).to.deep.equal({ success: true }) // 3rd request should be rate limited (covers default handler lines 208-209) const res3 = await request(url).get('/test').expect(429) c_expect(res3.body).to.have.property('error', 'rate_limit_exceeded') c_expect(res3.body).to.have.property('message', 'Too many requests. Please try again later.') }, 10000) test('Rate limit default keyGenerator uses x-forwarded-for', async () => { const port = 57013 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) // Don't provide a custom keyGenerator const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 1, // Very strict // NO keyGenerator - uses default with x-forwarded-for fallback } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // With trust proxy=1, req.ip is the rightmost entry of x-forwarded-for. // Two requests with the same rightmost IP share a bucket. const res1 = await request(url).get('/test').set('x-forwarded-for', '10.0.0.1').expect(200) c_expect(res1.body).to.deep.equal({ success: true }) // Second request from same IP (same rightmost x-forwarded-for) should be rate limited const res2 = await request(url).get('/test').set('x-forwarded-for', '10.0.0.1').expect(429) c_expect(res2.body).to.have.property('error', 'rate_limit_exceeded') // Wait for window to reset await new Promise(resolve => setTimeout(resolve, 1100)) // Different IP should have its own clean bucket const res3 = await request(url).get('/test').set('x-forwarded-for', '10.0.0.99').expect(200) c_expect(res3.body).to.deep.equal({ success: true }) }, 10000) test('Rate limit default handler with warning log', async () => { const port = 57014 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) // Don't provide custom handler - should use default const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 1, // NO custom handler - will use default (lines 215-229) } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Use up the limit await request(url).get('/test-endpoint').expect(200) // Trigger rate limit (should call default handler which logs via Logger) const rateLimitedRes = await request(url).get('/test-endpoint').expect(429) // Verify default handler was called and returned correct response (lines 215-229) c_expect(rateLimitedRes.body).to.deep.equal({ error: 'rate_limit_exceeded', message: 'Too many requests. Please try again later.', }) // The logging is done via Logger.info() which is tested separately // This test verifies the default handler functionality works }, 10000) test('Rate limit with Redis store sendCommand and prefix', async () => { const port = 57015 // Mock Redis client const sendCommandCalls: any[] = [] const mockRedisClient: any = { sendCommand: jest.fn(async (...args: any[]) => { sendCommandCalls.push(args) return 'OK' }), } // Test with custom prefix (covers lines 259-264) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: { prefix: 'custom:prefix:', // Custom prefix (line 263) }, } redisConnectionSpy = jest.spyOn(Redis, 'connection').mockResolvedValue(mockRedisClient) proxy = new Proxy( { routes: [], port, cache: redisCacheConfig, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Verify RedisStore was instantiated successfully with custom prefix (lines 259-264) // The fact that proxy loaded without errors means: // 1. Redis store was recognized (line 258) // 2. RedisStore was created with sendCommand and custom prefix (lines 260-263) // 3. The configuration is valid and accepted c_expect(proxy).to.exist }) test('Rate limit with Redis store default prefix', async () => { const port = 57016 const mockRedisClient: any = { sendCommand: jest.fn(async () => 'OK'), } // Don't provide prefix - should use default 'wapi:rl:' (line 252) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 5, store: 'redis', redis: {}, } redisConnectionSpy = jest.spyOn(Redis, 'connection').mockResolvedValue(mockRedisClient) proxy = new Proxy( { routes: [], port, cache: redisCacheConfig, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) // Should load successfully with default prefix await proxy.load() // Verify proxy is running c_expect(proxy).to.exist }) test('Rate limit Redis store keeps standalone sendCommand routing unchanged', async () => { const standaloneCalls: any[] = [] const mockRedisClient: any = { sendCommand: jest.fn(async (command: string[]) => { standaloneCalls.push(command) if (command[0] === 'SCRIPT' && command[1] === 'LOAD') return `sha:${command[2].length}` if (command[0] === 'EVALSHA') return [1, 1000] if (command[0] === 'DECR' || command[0] === 'DEL') return 1 return 'OK' }), } const containerProxy = new Proxy( { routes: [], port: 57030, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) ;(containerProxy as any).rateLimitRedisClient = mockRedisClient const store = (containerProxy as any).createRateLimitStore({ enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: {}, }) await store.init({ windowMs: 1000 } as any) await store.increment('alpha') await store.get('alpha') await store.decrement('alpha') await store.resetKey('alpha') c_expect(mockRedisClient.sendCommand.mock.calls.length).to.be.greaterThan(0) c_expect(standaloneCalls[0][0]).to.equal('SCRIPT') c_expect(standaloneCalls[0][1]).to.equal('LOAD') c_expect(standaloneCalls[0][2]).to.be.a('string') c_expect( mockRedisClient.sendCommand.mock.calls.every((call: any[]) => call.length === 1) ).to.equal(true) c_expect( mockRedisClient.sendCommand.mock.calls.every((call: any[]) => Array.isArray(call[0])) ).to.equal(true) c_expect(standaloneCalls.some(command => command[0] === 'EVALSHA')).to.equal(true) c_expect(standaloneCalls.some(command => command[0] === 'DECR')).to.equal(true) c_expect(standaloneCalls.some(command => command[0] === 'DEL')).to.equal(true) }) test('Rate limit Redis store routes cluster commands with firstKey and broadcasts SCRIPT LOAD', async () => { const clusterCalls: any[] = [] const masterOne = { sendCommand: jest.fn(async (command: string[]) => `sha:${command[2].length}`), } const masterTwo = { sendCommand: jest.fn(async (command: string[]) => `sha:${command[2].length}`), } const mockClusterClient: any = { getMasters: jest.fn(() => [masterOne, masterTwo]), sendCommand: jest.fn(async (firstKey: string, isReadOnly: boolean, command: string[]) => { clusterCalls.push([firstKey, isReadOnly, command]) if (command[0] === 'EVALSHA') return [1, 1000] if (command[0] === 'DECR' || command[0] === 'DEL') return 1 return 'OK' }), } const containerProxy = new Proxy( { routes: [], port: 57031, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) ;(containerProxy as any).rateLimitRedisClient = mockClusterClient const store = (containerProxy as any).createRateLimitStore({ enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: {}, }) await store.init({ windowMs: 1000 } as any) await store.increment('alpha') await store.get('alpha') await store.decrement('alpha') await store.resetKey('alpha') c_expect(mockClusterClient.getMasters.mock.calls.length).to.equal(2) c_expect(masterOne.sendCommand.mock.calls.length).to.equal(2) c_expect(masterTwo.sendCommand.mock.calls.length).to.equal(2) c_expect(masterOne.sendCommand.mock.calls[0][0][0]).to.equal('SCRIPT') c_expect(masterOne.sendCommand.mock.calls[0][0][1]).to.equal('LOAD') c_expect(masterOne.sendCommand.mock.calls[0][0][2]).to.be.a('string') c_expect(masterTwo.sendCommand.mock.calls[1][0][0]).to.equal('SCRIPT') c_expect(masterTwo.sendCommand.mock.calls[1][0][1]).to.equal('LOAD') c_expect(masterTwo.sendCommand.mock.calls[1][0][2]).to.be.a('string') const incrementCall = clusterCalls.find( ([, , command]) => command[0] === 'EVALSHA' && command.length === 6 ) const getCall = clusterCalls.find( ([, , command]) => command[0] === 'EVALSHA' && command.length === 4 ) const decrementCall = clusterCalls.find(([, , command]) => command[0] === 'DECR') const resetCall = clusterCalls.find(([, , command]) => command[0] === 'DEL') c_expect(incrementCall).to.not.equal(undefined) c_expect(incrementCall[0]).to.equal('wapi:rl:alpha') c_expect(incrementCall[1]).to.equal(false) c_expect(incrementCall[2][1]).to.be.a('string') c_expect(incrementCall[2].slice(2)).to.deep.equal(['1', 'wapi:rl:alpha', '0', '1000']) c_expect(getCall).to.not.equal(undefined) c_expect(getCall[0]).to.equal('wapi:rl:alpha') c_expect(getCall[1]).to.equal(true) c_expect(getCall[2][1]).to.be.a('string') c_expect(getCall[2].slice(2)).to.deep.equal(['1', 'wapi:rl:alpha']) c_expect(decrementCall).to.deep.equal(['wapi:rl:alpha', false, ['DECR', 'wapi:rl:alpha']]) c_expect(resetCall).to.deep.equal(['wapi:rl:alpha', false, ['DEL', 'wapi:rl:alpha']]) c_expect( mockClusterClient.sendCommand.mock.calls.every((call: any[]) => call.length === 3) ).to.equal(true) c_expect( mockClusterClient.sendCommand.mock.calls.every((call: any[]) => !Array.isArray(call[0])) ).to.equal(true) }) test('Rate limit Redis store reloads scripts on NOSCRIPT and retries EVALSHA', async () => { const clusterCalls: any[] = [] const masterOneClient = { sendCommand: jest.fn(async (command: string[]) => `sha:${command[2].length}`), } const masterTwoClient = { sendCommand: jest.fn(async (command: string[]) => `sha:${command[2].length}`), } const masterOne = { id: 'one', client: masterOneClient } const masterTwo = { id: 'two', client: Promise.resolve(masterTwoClient) } let incrementAttempts = 0 const mockClusterClient: any = { getMasters: jest.fn(() => [masterOne, masterTwo]), nodeClient: jest.fn(async (node: any) => node.client), sendCommand: jest.fn(async (firstKey: string, isReadOnly: boolean, command: string[]) => { clusterCalls.push([firstKey, isReadOnly, command]) if (command[0] === 'EVALSHA' && command.length === 6) { incrementAttempts += 1 if (incrementAttempts === 1) throw new Error('NOSCRIPT No matching script. Please use EVAL.') return [1, 1000] } if (command[0] === 'EVALSHA') return [1, 1000] if (command[0] === 'DECR' || command[0] === 'DEL') return 1 return 'OK' }), } const containerProxy = new Proxy( { routes: [], port: 57033, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) ;(containerProxy as any).rateLimitRedisClient = mockClusterClient const store = (containerProxy as any).createRateLimitStore({ enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: {}, }) await store.init({ windowMs: 1000 } as any) await store.increment('alpha') c_expect(incrementAttempts).to.equal(2) c_expect(mockClusterClient.getMasters.mock.calls.length).to.be.at.least(2) c_expect(masterOneClient.sendCommand.mock.calls.length).to.be.at.least(2) c_expect(masterTwoClient.sendCommand.mock.calls.length).to.be.at.least(2) const incrementCalls = clusterCalls.filter( ([, , command]) => command[0] === 'EVALSHA' && command.length === 6 ) c_expect(incrementCalls.length).to.equal(2) c_expect(incrementCalls[0]).to.deep.equal(incrementCalls[1]) }) test('Rate limit Redis store derives cluster routing metadata for direct key-based commands', async () => { const clusterCalls: any[] = [] const masterNode = { sendCommand: jest.fn(async (command: string[]) => `sha:${command[2]?.length || 0}`), } const mockClusterClient: any = { masters: [masterNode], sendCommand: jest.fn( async (firstKey: string | undefined, isReadOnly: boolean, command: string[]) => { clusterCalls.push([firstKey, isReadOnly, command]) return 'OK' } ), } const containerProxy = new Proxy( { routes: [], port: 57032, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) ;(containerProxy as any).rateLimitRedisClient = mockClusterClient const store = (containerProxy as any).createRateLimitStore({ enabled: true, windowMs: 1000, limit: 3, store: 'redis', redis: {}, }) await store.init({ windowMs: 1000 } as any) await store.sendCommand({ command: ['GET', 'wapi:rl:beta'] }) await store.sendCommand({ command: ['MGET', 'wapi:rl:beta', 'wapi:rl:gamma'] }) await store.sendCommand({ command: ['SET', 'wapi:rl:beta', 'value'] }) await store.sendCommand({ command: ['EVAL', 'return 1', '0'] }) await store.sendCommand({ command: [] }) c_expect(clusterCalls[0]).to.deep.equal(['wapi:rl:beta', true, ['GET', 'wapi:rl:beta']]) c_expect(clusterCalls[1]).to.deep.equal([ undefined, true, ['MGET', 'wapi:rl:beta', 'wapi:rl:gamma'], ]) c_expect(clusterCalls[2]).to.deep.equal([ 'wapi:rl:beta', false, ['SET', 'wapi:rl:beta', 'value'], ]) c_expect(clusterCalls[3]).to.deep.equal([undefined, false, ['EVAL', 'return 1', '0']]) c_expect(clusterCalls[4]).to.deep.equal([undefined, false, []]) }) test('Rate limit default keyGenerator - same bearer token shares a bucket', async () => { const port = 57017 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) // Build a fake but structurally-valid JWT for user1 const makeJwt = (sub: string) => { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url') return `${header}.${payload}.fakesignature` } const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, // NO custom keyGenerator - should default to bearer token extraction } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() const token = makeJwt('user1') // First 2 requests with same token should succeed await request(url).get('/test').set('Authorization', `Bearer ${token}`).expect(200) await request(url).get('/test').set('Authorization', `Bearer ${token}`).expect(200) // 3rd request from same user (same token sub) should be rate limited const rateLimitedRes = await request(url) .get('/test') .set('Authorization', `Bearer ${token}`) .expect(429) c_expect(rateLimitedRes.body).to.have.property('error', 'rate_limit_exceeded') }, 10000) test('Rate limit default keyGenerator - different users have separate buckets', async () => { const port = 57018 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const makeJwt = (sub: string) => { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url') return `${header}.${payload}.fakesignature` } const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() const user1Token = makeJwt('user1') const user2Token = makeJwt('user2') // User 1 exhausts their bucket await request(url).get('/test').set('Authorization', `Bearer ${user1Token}`).expect(200) await request(url).get('/test').set('Authorization', `Bearer ${user1Token}`).expect(200) await request(url).get('/test').set('Authorization', `Bearer ${user1Token}`).expect(429) // User 2 has their own independent bucket — should still be allowed const user2Res = await request(url) .get('/test') .set('Authorization', `Bearer ${user2Token}`) .expect(200) c_expect(user2Res.body).to.deep.equal({ success: true }) }, 10000) test('Rate limit default keyGenerator - unauthenticated requests fall back to IP', async () => { const port = 57019 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const rateLimitConfig: GlobalRateLimitConfig = { enabled: true, windowMs: 1000, limit: 2, } proxy = new Proxy( { routes: [], port, rateLimit: rateLimitConfig, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200, }) } ) await proxy.load() // Requests with no Authorization header are bucketed by IP (all from ::1 in tests) await request(url).get('/test').expect(200) await request(url).get('/test').expect(200) // Same IP, 3rd request exceeds the limit const rateLimitedRes = await request(url).get('/test').expect(429) c_expect(rateLimitedRes.body).to.have.property('error', 'rate_limit_exceeded') }, 10000) }) describe('Per-route rate limiting', () => { // @ts-ignore let mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}) let proxy: Proxy | null = null beforeAll(() => { // @ts-ignore mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {}) }) afterAll(() => { mockExit.mockRestore() }) beforeEach(() => { mockExit.mockReset() }) afterEach(async () => { if (proxy) { await proxy.unload() proxy = null } await new Promise(resolve => setTimeout(resolve, 100)) }) test('Per-route rate limit is applied independently of global', async () => { const port = 57020 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const routeRateLimit: RateLimitConfig = { limit: 2, windowMs: 1000 } proxy = new Proxy( { routes: [ { path: '/api/limited', method: HttpMethod.GET, rateLimit: routeRateLimit, handler: async () => ({}) as any, }, ], port, // No global rate limit — only per-route }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() // First 2 requests succeed await request(url).get('/api/limited').expect(200) await request(url).get('/api/limited').expect(200) // 3rd request is blocked by the per-route rate limit const res = await request(url).get('/api/limited').expect(429) c_expect(res.body).to.have.property('error', 'rate_limit_exceeded') // Wait for the window to reset and verify recovery await new Promise(resolve => setTimeout(resolve, 1100)) await request(url).get('/api/limited').expect(200) }, 10000) test('rateLimit: false exempts a route from the global rate limit', async () => { const port = 57021 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) proxy = new Proxy( { routes: [ { path: '/api/exempt', method: HttpMethod.GET, rateLimit: false, handler: async () => ({}) as any, }, ], port, rateLimit: { enabled: true, limit: 2, windowMs: 5000 }, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() // Global limit is 2, but /api/exempt bypasses it — all 5 requests should succeed for (let i = 0; i < 5; i++) { const res = await request(url).get('/api/exempt').expect(200) c_expect(res.body).to.deep.equal({ success: true }) } // A non-exempt path still hits the global limit await request(url).get('/other-path').expect(200) await request(url).get('/other-path').expect(200) await request(url).get('/other-path').expect(429) }, 10000) test('Per-route and global limits operate independently', async () => { const port = 57022 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) proxy = new Proxy( { routes: [ { path: '/api/strict', method: HttpMethod.GET, rateLimit: { limit: 2, windowMs: 5000 }, handler: async () => ({}) as any, }, ], port, rateLimit: { enabled: true, limit: 5, windowMs: 5000 }, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() // /api/strict has a tighter per-route limit (2); hits 429 on 3rd request await request(url).get('/api/strict').expect(200) await request(url).get('/api/strict').expect(200) await request(url).get('/api/strict').expect(429) // /api/strict requests do NOT consume global tokens — /other-path still has its full budget for (let i = 0; i < 5; i++) { const res = await request(url).get('/other-path').expect(200) c_expect(res.body).to.deep.equal({ success: true }) } // 6th request to /other-path exhausts the global limit await request(url).get('/other-path').expect(429) }, 15000) test('Per-route keyGenerator ip uses client IP buckets', async () => { const port = 57023 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) proxy = new Proxy( { routes: [ { path: '/api/ip-bucket', method: HttpMethod.GET, rateLimit: { limit: 1, windowMs: 5000, keyGenerator: 'ip' }, handler: async () => ({}) as any, }, ], port, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() await request(url).get('/api/ip-bucket').set('x-forwarded-for', '10.0.0.1').expect(200) await request(url).get('/api/ip-bucket').set('x-forwarded-for', '10.0.0.1').expect(429) await request(url).get('/api/ip-bucket').set('x-forwarded-for', '10.0.0.2').expect(200) }, 10000) test('Per-route keyGenerator userId buckets authenticated users separately', async () => { const port = 57024 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) const makeJwt = (payload: Record) => { const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url') const body = Buffer.from(JSON.stringify(payload)).toString('base64url') return `${header}.${body}.fakesignature` } proxy = new Proxy( { routes: [ { path: '/api/user-bucket', method: HttpMethod.GET, rateLimit: { limit: 1, windowMs: 5000, keyGenerator: 'userId' }, handler: async () => ({}) as any, }, ], port, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() const user1Token = makeJwt({ sub: 'user-1' }) const user2Token = makeJwt({ userId: 'user-2' }) await request(url) .get('/api/user-bucket') .set('Authorization', `Bearer ${user1Token}`) .expect(200) await request(url) .get('/api/user-bucket') .set('Authorization', `Bearer ${user1Token}`) .expect(429) await request(url) .get('/api/user-bucket') .set('Authorization', `Bearer ${user2Token}`) .expect(200) }, 10000) test('Per-route keyGenerator userId falls back to IP for malformed bearer tokens', async () => { const port = 57025 const url = defaultUrl.replace(String(Globals.Listener_HTTP_DefaultPort), String(port)) proxy = new Proxy( { routes: [ { path: '/api/user-fallback', method: HttpMethod.GET, rateLimit: { limit: 1, windowMs: 5000, keyGenerator: 'userId' }, handler: async () => ({}) as any, }, ], port, }, async (event: APIGatewayProxyEvent, context: Context) => { context.succeed({ body: JSON.stringify({ success: true }), statusCode: 200 }) } ) await proxy.load() await request(url) .get('/api/user-fallback') .set('Authorization', 'Bearer invalid.token.payload') .set('x-forwarded-for', '10.0.0.3') .expect(200) await request(url) .get('/api/user-fallback') .set('Authorization', 'Bearer invalid.token.payload') .set('x-forwarded-for', '10.0.0.3') .expect(429) await request(url) .get('/api/user-fallback') .set('Authorization', 'Bearer invalid.token.payload') .set('x-forwarded-for', '10.0.0.4') .expect(200) }, 10000) }) export {}