Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions __tests__/api/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,27 @@ import prisma from "@/lib/prisma";

const mockQueryRaw = prisma.$queryRaw as Mock;

function createMockReqRes(method = "GET"): {
// Each call gets a unique IP by default so the per-IP rate limiter
// never interferes across tests. Pass `ip` to share a bucket on purpose.
let nextIp = 0;

function createMockReqRes(
method = "GET",
ip?: string
): {
req: NextApiRequest;
res: NextApiResponse;
} {
const req = { method } as NextApiRequest;
nextIp++;
const req = {
method,
headers: { "x-forwarded-for": ip ?? `198.51.100.${nextIp}` },
socket: {},
} as unknown as NextApiRequest;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis(),
} as unknown as NextApiResponse;
return { req, res };
}
Expand Down Expand Up @@ -152,4 +165,23 @@ describe("GET /api/health", () => {
expect(dbCheck.status).toBe("unhealthy");
expect(typeof dbCheck.responseTime).toBe("number");
});

it("returns 429 with Retry-After after 30 requests per minute from one IP", async () => {
const ip = "203.0.113.50";

for (let i = 0; i < 30; i++) {
const { req, res } = createMockReqRes("GET", ip);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(200);
}

const { req, res } = createMockReqRes("GET", ip);
await handler(req, res);

expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith({
error: "Too many requests. Please try again later.",
});
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number));
});
});
31 changes: 31 additions & 0 deletions __tests__/lib/j0di3/j0di3-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,35 @@ describe("j0di3Proxy", () => {
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
consoleSpy.mockRestore();
});

it("returns 429 with Retry-After after 30 requests per minute for one troop", async () => {
mockJ0di3.mockResolvedValue({ data: { ok: true } });

const handler = j0di3Proxy("POST", "/api/v1/learning/explain");
const user = {
id: "user-rl",
name: "Rate Limited",
email: "rl@test.com",
role: "STUDENT",
troopId: "troop-rate-limit-test",
troopToken: "troop-token-rl",
};

for (let i = 0; i < 30; i++) {
const { req, res } = createMockReqRes({ user } as Partial<NextApiRequest>);
await handler(req, res);
expect(res.status).not.toHaveBeenCalledWith(429);
}
expect(mockJ0di3).toHaveBeenCalledTimes(30);

const { req, res } = createMockReqRes({ user } as Partial<NextApiRequest>);
await handler(req, res);

expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith({
error: "Too many requests. Please try again later.",
});
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number));
expect(mockJ0di3).toHaveBeenCalledTimes(30);
});
});
83 changes: 81 additions & 2 deletions __tests__/lib/rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
import type { NextApiRequest } from "next";
import type { NextApiRequest, NextApiResponse } from "next";
import { checkRateLimit, enforceRateLimit, getClientIp } from "@/lib/rate-limit";

describe("rate-limit", () => {
describe("checkRateLimit", () => {
Expand Down Expand Up @@ -83,4 +83,83 @@ describe("rate-limit", () => {
expect(getClientIp(req)).toBe("unknown");
});
});

describe("enforceRateLimit", () => {
function createMockReqRes(ip: string): { req: NextApiRequest; res: NextApiResponse } {
const req = {
headers: { "x-forwarded-for": ip },
socket: { remoteAddress: ip },
} as unknown as NextApiRequest;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis(),
} as unknown as NextApiResponse;
return { req, res };
}

it("should allow requests under the limit and set rate limit headers", () => {
const { req, res } = createMockReqRes("20.0.0.1");

const allowed = enforceRateLimit(req, res, {
name: "test-allow",
maxRequests: 2,
windowMs: 60000,
});

expect(allowed).toBe(true);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).toHaveBeenCalledWith("X-RateLimit-Remaining", 1);
});

it("should send 429 with Retry-After when the limit is exceeded", () => {
const options = { name: "test-block", maxRequests: 2, windowMs: 60000 };
for (let i = 0; i < 2; i++) {
const { req, res } = createMockReqRes("20.0.0.2");
enforceRateLimit(req, res, options);
}

const { req, res } = createMockReqRes("20.0.0.2");
const allowed = enforceRateLimit(req, res, options);

expect(allowed).toBe(false);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith({
error: "Too many requests. Please try again later.",
});
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number));
});

it("should not share buckets between different names", () => {
const ip = "20.0.0.3";
const first = createMockReqRes(ip);
enforceRateLimit(first.req, first.res, {
name: "endpoint-a",
maxRequests: 1,
windowMs: 60000,
});

const { req, res } = createMockReqRes(ip);
const allowed = enforceRateLimit(req, res, {
name: "endpoint-b",
maxRequests: 1,
windowMs: 60000,
});

expect(allowed).toBe(true);
});

it("should use the provided key instead of the client IP", () => {
const options = { name: "test-key", maxRequests: 1, windowMs: 60000 };
const first = createMockReqRes("20.0.0.4");
enforceRateLimit(first.req, first.res, { ...options, key: "troop:abc" });

// Different IP, same key — still blocked.
const { req, res } = createMockReqRes("20.0.0.5");
const allowed = enforceRateLimit(req, res, { ...options, key: "troop:abc" });

expect(allowed).toBe(false);
expect(res.status).toHaveBeenCalledWith(429);
});
});
});
39 changes: 37 additions & 2 deletions __tests__/pages/api/contact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,27 @@ vi.mock("@/pages/api/api-helpers/classify-contact", () => ({

vi.mock("axios");

function createMockReqRes(body: Record<string, unknown>): {
// Each call gets a unique IP by default so the per-IP rate limiter
// never interferes across tests. Pass `ip` to share a bucket on purpose.
let nextIp = 0;

function createMockReqRes(
body: Record<string, unknown>,
ip?: string
): {
req: NextApiRequest;
res: NextApiResponse;
} {
const req = { body } as NextApiRequest;
nextIp++;
const req = {
body,
headers: { "x-forwarded-for": ip ?? `198.51.100.${nextIp}` },
socket: {},
} as unknown as NextApiRequest;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
setHeader: vi.fn().mockReturnThis(),
} as unknown as NextApiResponse;
return { req, res };
}
Expand Down Expand Up @@ -243,4 +256,26 @@ describe("POST /api/contact", () => {
expect(payload.text).toContain("I want to join the bootcamp");
});
});

describe("rate limiting", () => {
it("should return 429 with Retry-After after 5 requests per minute from one IP", async () => {
const ip = "203.0.113.99";

for (let i = 0; i < 5; i++) {
const { req, res } = createMockReqRes({}, ip);
await handler(req, res);
// Under the limit: requests reach validation, not the limiter.
expect(res.status).toHaveBeenCalledWith(422);
}

const { req, res } = createMockReqRes({}, ip);
await handler(req, res);

expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith({
error: "Too many requests. Please try again later.",
});
expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number));
});
});
});
36 changes: 36 additions & 0 deletions docs/RATE_LIMITS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# API Rate Limits

Public API endpoints are rate limited via the in-memory sliding-window limiter in
`src/lib/rate-limit.ts` (`enforceRateLimit`). Buckets are namespaced per endpoint, so
hitting one endpoint never consumes another endpoint's budget.

## Limits

| Endpoint | Limit | Window | Keyed by |
| --------------- | ---------- | ------ | ----------------------------------------- |
| `/api/j0di3/*` | 30 req/min | 60s | troopId, falling back to user id, then IP |
| `/api/contact` | 5 req/min | 60s | client IP |
| `/api/health` | 30 req/min | 60s | client IP |

The J0dI3 limit is enforced centrally in `src/lib/j0di3-proxy.ts`, so every
`/api/j0di3/*` route is covered in one place.

Other endpoints (e.g. `/api/military-resume/*`) apply their own limits directly with
`checkRateLimit` — see the individual handlers.

## Response on limit

When a limit is exceeded the endpoint responds:

- Status: `429`
- Headers: `Retry-After` (seconds until the window resets), plus
`X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response
- Body: `{ "error": "Too many requests. Please try again later." }`

## Scaling note

The limiter keeps its counters in process memory, which is only correct on a
single instance. If the app scales to multiple instances (or heavy serverless
fan-out makes per-instance counters too leaky), swap the `Map` in
`src/lib/rate-limit.ts` for a shared store such as Upstash Redis — the
`enforceRateLimit` call sites stay the same.
80 changes: 80 additions & 0 deletions public/swagger-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,86 @@
}
}
}
},
"/api/contact": {
"post": {
"summary": "Submit the contact form",
"description": "Validates the submission, filters spam, and forwards it to Slack. Rate limited to 5 requests per minute per IP.",
"tags": [
"Contact"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"email",
"message"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
},
"phone": {
"type": "string"
},
"subject": {
"type": "string"
},
"message": {
"type": "string"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Message accepted"
},
"400": {
"description": "Message too short"
},
"422": {
"description": "Missing required fields"
},
"429": {
"description": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header."
},
"500": {
"description": "Failed to deliver the message"
}
}
}
},
"/api/health": {
"get": {
"summary": "Service health check",
"description": "Reports database and environment health. Rate limited to 30 requests per minute per IP.",
"tags": [
"Health"
],
"responses": {
"200": {
"description": "Service is healthy or degraded"
},
"405": {
"description": "Method not allowed"
},
"429": {
"description": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header."
},
"503": {
"description": "Service is unhealthy"
}
}
}
}
},
"tags": []
Expand Down
11 changes: 11 additions & 0 deletions src/lib/j0di3-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import axios from "axios";
import type { NextApiRequest, NextApiResponse } from "next";
import j0di3 from "@/lib/j0di3-client";
import { enforceRateLimit } from "@/lib/rate-limit";
import { type AuthenticatedRequest, requireAuth, requireRole } from "@/lib/rbac";

type Method = "GET" | "POST" | "PATCH" | "DELETE";
Expand All @@ -19,6 +20,16 @@ async function dispatch(
const troopId = req.user!.troopId;
const troopToken = req.user!.troopToken;

// 30 req/min per troop (falls back to user id, then client IP).
const rateKey = troopId ? `troop:${troopId}` : req.user?.id ? `user:${req.user.id}` : undefined;
const limited = !enforceRateLimit(req, res, {
name: "j0di3",
maxRequests: 30,
windowMs: 60 * 1000,
key: rateKey,
});
if (limited) return;

if (injectTroopId && !troopId) {
return res
.status(400)
Expand Down
Loading
Loading