import type { NextFunction, Request, Response } from 'express'; import { createRateLimitMiddleware, readRateLimitConfig } from './rate-limit.middleware'; function makeReq(opts: { ip?: string; path: string; sessionID?: string; session?: { user?: { oid: string } }; }): Request { return { ip: opts.ip ?? '1.2.3.4', path: opts.path, sessionID: opts.sessionID, session: opts.session, headers: {}, method: 'POST', get: () => undefined, } as unknown as Request; } function makeRes(): Response & { status: jest.Mock; json: jest.Mock; setHeader: jest.Mock } { const res = { status: jest.fn(), json: jest.fn(), setHeader: jest.fn(), getHeader: jest.fn(), headersSent: false, }; res.status.mockReturnValue(res); res.json.mockReturnValue(res); return res as unknown as Response & { status: jest.Mock; json: jest.Mock; setHeader: jest.Mock; }; } describe('readRateLimitConfig', () => { const origGeneral = process.env['RATE_LIMIT_PER_MINUTE']; const origAuth = process.env['RATE_LIMIT_AUTH_PER_MINUTE']; afterEach(() => { restore('RATE_LIMIT_PER_MINUTE', origGeneral); restore('RATE_LIMIT_AUTH_PER_MINUTE', origAuth); }); it('returns conservative defaults when env is unset', () => { delete process.env['RATE_LIMIT_PER_MINUTE']; delete process.env['RATE_LIMIT_AUTH_PER_MINUTE']; expect(readRateLimitConfig()).toEqual({ perMinute: 120, authPerMinute: 10 }); }); it('parses positive integer overrides', () => { process.env['RATE_LIMIT_PER_MINUTE'] = '300'; process.env['RATE_LIMIT_AUTH_PER_MINUTE'] = '5'; expect(readRateLimitConfig()).toEqual({ perMinute: 300, authPerMinute: 5 }); }); it('rejects non-positive integers', () => { process.env['RATE_LIMIT_PER_MINUTE'] = '0'; expect(() => readRateLimitConfig()).toThrow(/must be a positive integer/); }); it('rejects non-numeric values', () => { process.env['RATE_LIMIT_PER_MINUTE'] = 'fast'; expect(() => readRateLimitConfig()).toThrow(/must be a positive integer/); }); }); describe('createRateLimitMiddleware', () => { it('lets /api/health through without counting', async () => { const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 1 }); const next = jest.fn() as unknown as NextFunction; // Hammer /api/health far above the limit — still passes. for (let i = 0; i < 5; i++) { (next as jest.Mock).mockClear(); const res = makeRes(); await mw(makeReq({ path: '/api/health' }), res, next); expect(next).toHaveBeenCalledTimes(1); expect(res.status).not.toHaveBeenCalledWith(429); } }); it('enforces the general bucket: passes up to perMinute then 429s', async () => { const mw = createRateLimitMiddleware({ perMinute: 2, authPerMinute: 99 }); const next = jest.fn() as unknown as NextFunction; const ip = '10.0.0.1'; // First two: pass. for (let i = 0; i < 2; i++) { (next as jest.Mock).mockClear(); const res = makeRes(); await mw(makeReq({ ip, path: '/api/whatever' }), res, next); expect(next).toHaveBeenCalled(); } // Third: rejected. const res = makeRes(); await mw(makeReq({ ip, path: '/api/whatever' }), res, next); expect(res.status).toHaveBeenCalledWith(429); const body = res.json.mock.calls[0]?.[0] as { error: { code: string; message: string } }; expect(body.error.code).toBe('rate_limited'); expect(body.error.message).toBe('Too many requests'); }); it('enforces the stricter bucket on /api/auth/login', async () => { const mw = createRateLimitMiddleware({ perMinute: 99, authPerMinute: 1 }); const next = jest.fn() as unknown as NextFunction; const ip = '10.0.0.2'; // First /login: pass. let res = makeRes(); await mw(makeReq({ ip, path: '/api/auth/login' }), res, next); expect(res.status).not.toHaveBeenCalledWith(429); // Second /login: blocked (authPerMinute=1). res = makeRes(); await mw(makeReq({ ip, path: '/api/auth/login' }), res, next); expect(res.status).toHaveBeenCalledWith(429); }); it('keys per-session when authenticated, isolating buckets', async () => { const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 }); const next = jest.fn() as unknown as NextFunction; const session = { user: { oid: 'u1' } }; // session sid-A: 1 hit allowed let res = makeRes(); await mw(makeReq({ path: '/api/x', sessionID: 'sid-A', session }), res, next); expect(res.status).not.toHaveBeenCalledWith(429); // session sid-A: 2nd hit blocked res = makeRes(); await mw(makeReq({ path: '/api/x', sessionID: 'sid-A', session }), res, next); expect(res.status).toHaveBeenCalledWith(429); // session sid-B: independent bucket, 1st hit still allowed res = makeRes(); await mw(makeReq({ path: '/api/x', sessionID: 'sid-B', session }), res, next); expect(res.status).not.toHaveBeenCalledWith(429); }); it('keys per-IP when anonymous (no req.session.user)', async () => { const mw = createRateLimitMiddleware({ perMinute: 1, authPerMinute: 99 }); const next = jest.fn() as unknown as NextFunction; let res = makeRes(); await mw(makeReq({ ip: '10.0.0.10', path: '/api/x' }), res, next); expect(res.status).not.toHaveBeenCalledWith(429); // Same IP, 2nd hit blocked. res = makeRes(); await mw(makeReq({ ip: '10.0.0.10', path: '/api/x' }), res, next); expect(res.status).toHaveBeenCalledWith(429); // Different IP, independent bucket. res = makeRes(); await mw(makeReq({ ip: '10.0.0.11', path: '/api/x' }), res, next); expect(res.status).not.toHaveBeenCalledWith(429); }); }); function restore(name: string, value: string | undefined): void { if (value === undefined) { delete process.env[name]; } else { process.env[name] = value; } }