From 3dafac3de0b7a05bc85fab57b92182745fded99a Mon Sep 17 00:00:00 2001 From: ranjan1969x <342439069+ranjan1969x@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:18:57 +0545 Subject: [PATCH] test(middleware): cover rate limiter distributed counters, key isolation, and reset headers --- .../middleware/__tests__/rateLimiter.test.ts | 171 +++++++++++++++++- 1 file changed, 170 insertions(+), 1 deletion(-) diff --git a/backend/src/middleware/__tests__/rateLimiter.test.ts b/backend/src/middleware/__tests__/rateLimiter.test.ts index 13194e2c5..9333eff5b 100644 --- a/backend/src/middleware/__tests__/rateLimiter.test.ts +++ b/backend/src/middleware/__tests__/rateLimiter.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals'; +import express, { type Express } from 'express'; +import request from 'supertest'; const mockLoggerError = jest.fn(); const mockLoggerWarn = jest.fn(); @@ -30,7 +32,13 @@ jest.unstable_mockModule('redis', () => ({ }), })); -const { createRateLimiter } = await import('../rateLimiter.js'); +const { + createRateLimiter, + challengeRateLimiter, + loginRateLimiter, + ipLoginRateLimiter, + verifyRateLimiter, +} = await import('../rateLimiter.js'); const originalNodeEnv = process.env.NODE_ENV; const originalJestWorkerId = process.env.JEST_WORKER_ID; @@ -88,3 +96,164 @@ describe('rateLimiter Redis client error handling (#1852)', () => { expect(mockLoggerError).not.toHaveBeenCalled(); }); }); + +describe('distributed counters, key isolation, and reset headers (#2091)', () => { + const WINDOW_MS = 60_000; + let clock = Date.UTC(2026, 5, 1); + const nowSpy = jest.spyOn(Date, 'now'); + + const buildTestApp = (): Express => { + const app = express(); + app.set('trust proxy', 'loopback'); + app.use(express.json()); + + app.post('/test/login', loginRateLimiter, (_req, res) => { + res.json({ ok: true }); + }); + app.post('/test/ip-login', ipLoginRateLimiter, (_req, res) => { + res.json({ ok: true }); + }); + app.post('/test/challenge', challengeRateLimiter, (_req, res) => { + res.json({ ok: true }); + }); + app.post('/test/verify', verifyRateLimiter, (_req, res) => { + res.json({ ok: true }); + }); + + return app; + }; + + const app = buildTestApp(); + + beforeEach(() => { + clock += WINDOW_MS * 10; + nowSpy.mockImplementation(() => clock); + }); + + afterEach(() => { + nowSpy.mockReset(); + }); + + describe('key isolation across endpoints and identity dimensions', () => { + it('loginRateLimiter compound key isolates distinct publicKeys on the same IP', async () => { + const ip = '198.51.100.22'; + + // User A makes 5 requests from ip + for (let i = 0; i < 5; i++) { + const res = await request(app) + .post('/test/login') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_ALICE' }); + expect(res.status).toBe(200); + } + + // User A 6th request is blocked (429) + const blockedA = await request(app) + .post('/test/login') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_ALICE' }); + expect(blockedA.status).toBe(429); + expect(blockedA.headers['retry-after']).toBe('60'); + expect(blockedA.body).toEqual({ + success: false, + message: 'Too many login attempts, please try again later.', + }); + + // User B from same IP is NOT blocked because key is compound (ip:publicKey) + const resB = await request(app) + .post('/test/login') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_BOB' }); + expect(resB.status).toBe(200); + }); + + it('ipLoginRateLimiter enforces aggregate limit per IP regardless of publicKey', async () => { + const ip = '198.51.100.33'; + + for (let i = 0; i < 5; i++) { + const res = await request(app) + .post('/test/ip-login') + .set('X-Forwarded-For', ip) + .send({ publicKey: `G_ACCOUNT_${i}` }); + expect(res.status).toBe(200); + } + + const blocked = await request(app) + .post('/test/ip-login') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_FRESH_ACCOUNT' }); + expect(blocked.status).toBe(429); + expect(blocked.headers['retry-after']).toBe('60'); + expect(blocked.body).toEqual({ + success: false, + message: 'Too many login attempts from this IP, please try again later.', + }); + }); + }); + + describe('reset and retry-after header contracts', () => { + it('challengeRateLimiter sets standard headers and Retry-After upon exhaustion', async () => { + const ip = '198.51.100.44'; + + for (let i = 0; i < 10; i++) { + const res = await request(app) + .post('/test/challenge') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_CHALLENGE_USER' }); + expect(res.status).toBe(200); + expect(res.headers['ratelimit-remaining']).toBe(String(10 - (i + 1))); + } + + const blocked = await request(app) + .post('/test/challenge') + .set('X-Forwarded-For', ip) + .send({ publicKey: 'G_CHALLENGE_USER' }); + expect(blocked.status).toBe(429); + expect(blocked.headers['retry-after']).toBe('60'); + expect(blocked.body).toEqual({ + success: false, + message: 'Too many challenge requests, please try again later.', + }); + }); + + it('verifyRateLimiter sets Retry-After on 429 response', async () => { + const ip = '198.51.100.55'; + + for (let i = 0; i < 10; i++) { + const res = await request(app).post('/test/verify').set('X-Forwarded-For', ip).send({}); + expect(res.status).toBe(200); + } + + const blocked = await request(app).post('/test/verify').set('X-Forwarded-For', ip).send({}); + expect(blocked.status).toBe(429); + expect(blocked.headers['retry-after']).toBe('60'); + expect(blocked.body).toEqual({ + success: false, + message: 'Too many verification attempts', + }); + }); + }); + + describe('Redis outages and fail-open resilience in production mode', () => { + it('fails open when Redis sendCommand rejects or times out', async () => { + delete (process.env as Record).JEST_WORKER_ID; + process.env.NODE_ENV = 'development'; + + mockSendCommand.mockRejectedValue(new Error('ETIMEDOUT: Redis node unreachable')); + + const resilientLimiter = createRateLimiter(1, 1, 'resilience-test'); + const testApp = express(); + testApp.set('trust proxy', 'loopback'); + testApp.get('/test/resilience', resilientLimiter, (_req, res) => { + res.json({ success: true }); + }); + + const res = await request(testApp) + .get('/test/resilience') + .set('X-Forwarded-For', '10.200.1.1'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + }); + }); +});