Files
vyndr/tests/unit/rateLimitMiddleware.test.js
T

134 lines
4.5 KiB
JavaScript

const { createRateLimit } = require('../../src/middleware/rateLimit');
function mockReqRes(ip = '1.2.3.4') {
const req = { ip, headers: {}, socket: { remoteAddress: ip } };
const headers = {};
const res = {
statusCode: 200,
body: null,
set(k, v) { headers[k] = v; return res; },
status(c) { res.statusCode = c; return res; },
json(b) { res.body = b; return res; },
};
return { req, res, headers };
}
describe('rateLimit middleware', () => {
test('allows under-limit calls', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 3 });
const next = jest.fn();
for (let i = 0; i < 3; i += 1) {
const { req, res } = mockReqRes();
mw(req, res, next);
}
expect(next).toHaveBeenCalledTimes(3);
});
test('429s on the (max+1)th call within the window', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 2 });
const next = jest.fn();
let lastRes;
for (let i = 0; i < 3; i += 1) {
const { req, res } = mockReqRes('5.5.5.5');
mw(req, res, next);
lastRes = res;
}
expect(next).toHaveBeenCalledTimes(2);
expect(lastRes.statusCode).toBe(429);
expect(lastRes.body).toEqual({ error: 'Too many requests' });
});
test('429 response carries Retry-After header', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
const next = jest.fn();
let lastHeaders;
let lastRes;
for (let i = 0; i < 2; i += 1) {
const { req, res, headers } = mockReqRes('6.6.6.6');
mw(req, res, next);
lastRes = res; lastHeaders = headers;
}
expect(lastRes.statusCode).toBe(429);
expect(lastHeaders['Retry-After']).toBeDefined();
expect(parseInt(lastHeaders['Retry-After'], 10)).toBeGreaterThan(0);
});
test('different IPs each get their own quota', () => {
const mw = createRateLimit({ windowMs: 60_000, max: 1 });
const next = jest.fn();
const r1 = mockReqRes('7.7.7.7');
const r2 = mockReqRes('8.8.8.8');
mw(r1.req, r1.res, next);
mw(r2.req, r2.res, next);
expect(next).toHaveBeenCalledTimes(2);
expect(r1.res.statusCode).toBe(200);
expect(r2.res.statusCode).toBe(200);
});
test('window expiry releases the quota', async () => {
const mw = createRateLimit({ windowMs: 60, max: 1 });
const next = jest.fn();
const a = mockReqRes('9.9.9.9');
mw(a.req, a.res, next); // counts as 1
const b = mockReqRes('9.9.9.9');
mw(b.req, b.res, next); // 429
expect(b.res.statusCode).toBe(429);
await new Promise((r) => setTimeout(r, 90));
const c = mockReqRes('9.9.9.9');
mw(c.req, c.res, next); // back to OK
expect(next).toHaveBeenCalledTimes(2);
});
test('eviction trims the IP table when capacity exceeded', () => {
const { __internals } = require('../../src/middleware/rateLimit');
// We can't directly read the internal Map, so just confirm the
// module exposes MAX_TRACKED_IPS as a sanity floor.
expect(__internals.MAX_TRACKED_IPS).toBeGreaterThanOrEqual(1000);
});
});
describe('analyze routes wired to the middleware', () => {
test('hits a 429 on the 11th identical-IP call', async () => {
const express = require('express');
process.env.NBA_STATS_URL = 'http://localhost:9999'; // unreachable, but
// the rate limit fires before we ever try to call upstream.
const analyze = require('../../src/routes/analyze');
const app = express();
app.use(express.json());
app.use('/api/analyze', analyze);
const http = require('http');
const server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s));
});
const port = server.address().port;
function postOnce(path = '/api/analyze/prop') {
return new Promise((resolve) => {
const req = http.request({
host: '127.0.0.1', port, path, method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode }));
});
// Empty body — validator will 400 within the window, but the
// rate-limit middleware runs first and counts the hit.
req.end('{}');
});
}
const statuses = [];
for (let i = 0; i < 11; i += 1) {
// eslint-disable-next-line no-await-in-loop
statuses.push((await postOnce()).status);
}
server.close();
const tooMany = statuses.filter((s) => s === 429).length;
expect(tooMany).toBeGreaterThanOrEqual(1);
});
});