From 2a98b6f701046048cc7997229063a36efa82bc4d Mon Sep 17 00:00:00 2001 From: Edoka Isaac Date: Sat, 25 Jul 2026 23:37:07 +0100 Subject: [PATCH] setup rate limiting --- .gitignore | 3 + src/routes/predictions.ts | 1 + tests/predictionsPerUserRateLimit.test.ts | 76 +++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 tests/predictionsPerUserRateLimit.test.ts diff --git a/.gitignore b/.gitignore index c1afaa7..49dc035 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ coverage .vscode *.tsbuildinfo tests/integration/.container-info.json +.agents/ +issue.md +skills-lock.json \ No newline at end of file diff --git a/src/routes/predictions.ts b/src/routes/predictions.ts index 8df415e..dfd76bc 100644 --- a/src/routes/predictions.ts +++ b/src/routes/predictions.ts @@ -3,6 +3,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { requireAuth } from "../middleware/requireAuth"; import { createPerUserRateLimiter } from "../middleware/rateLimit"; +import { accessLog } from "../middleware/accessLog"; import { getPredictionExplanation } from "../services/predictionExplainService"; import cancelRouter from "./predictions/cancel"; import { createShareRouter } from "./predictions/share"; diff --git a/tests/predictionsPerUserRateLimit.test.ts b/tests/predictionsPerUserRateLimit.test.ts new file mode 100644 index 0000000..bc78e88 --- /dev/null +++ b/tests/predictionsPerUserRateLimit.test.ts @@ -0,0 +1,76 @@ +import request from "supertest"; +import express from "express"; +import { createPerUserRateLimiter } from "../src/middleware/rateLimit"; + +jest.mock("../src/services/auditService", () => ({ + createAuditLog: jest.fn().mockResolvedValue(undefined), +})); + +function makeApp(limit = 2) { + const app = express(); + app.use(express.json()); + + // Mock auth middleware populating req.user + app.use((req, _res, next) => { + const userId = req.headers["x-test-user"]; + if (typeof userId === "string") { + (req as unknown as { user?: { id: string } }).user = { id: userId }; + } + next(); + }); + + // Predictions router rate limiter middleware + app.use( + "/api/predictions", + createPerUserRateLimiter({ + windowMs: 60_000, + limit, + keyGenerator: (req) => { + const userId = (req as unknown as { user?: { id: string } }).user?.id; + if (typeof userId === "string" && userId.trim().length > 0) { + return `predictions:${userId}`; + } + return `predictions:unknown`; + }, + }), + ); + + app.get("/api/predictions", (_req, res) => { + res.json({ data: [], nextCursor: null }); + }); + + return app; +} + +describe("GET /api/predictions — per-user rate limiting (#401, #484)", () => { + it("enforces rate limit independently per authenticated user", async () => { + const app = makeApp(2); + + expect((await request(app).get("/api/predictions").set("x-test-user", "user-1")).status).toBe(200); + expect((await request(app).get("/api/predictions").set("x-test-user", "user-1")).status).toBe(200); + expect((await request(app).get("/api/predictions").set("x-test-user", "user-1")).status).toBe(429); + + // Second user is not blocked (independent bucket) + const user2Res = await request(app).get("/api/predictions").set("x-test-user", "user-2"); + expect(user2Res.status).toBe(200); + }); + + it("returns standard 429 error envelope with Retry-After header", async () => { + const app = makeApp(1); + + await request(app).get("/api/predictions").set("x-test-user", "user-1"); + const blockedRes = await request(app).get("/api/predictions").set("x-test-user", "user-1"); + + expect(blockedRes.status).toBe(429); + expect(blockedRes.body.error).toBeDefined(); + expect(blockedRes.body.error.code).toBe("rate_limit_exceeded"); + expect(blockedRes.body.error.message).toBe("Too many requests"); + + const retryAfterHeader = blockedRes.headers["retry-after"]; + expect(retryAfterHeader).toBeDefined(); + expect(Number(retryAfterHeader)).toBeGreaterThanOrEqual(1); + + expect(blockedRes.body.error.retryAfter).toBe(Number(retryAfterHeader)); + expect(typeof blockedRes.body.error.resetAt).toBe("string"); + }); +});