diff --git a/src/app/api/students/token/route.test.ts b/src/app/api/students/token/route.test.ts index 44f27222..2a66e24b 100644 --- a/src/app/api/students/token/route.test.ts +++ b/src/app/api/students/token/route.test.ts @@ -39,6 +39,10 @@ function request(body: unknown) { beforeEach(() => { vi.clearAllMocks(); + + // The route's rate limiter is module-scoped; reset modules so each test + // gets a fresh limiter and cannot inherit another test's rate-limit state. + vi.resetModules(); }); test("rejects a student session before minting a preview token", async () => { @@ -113,3 +117,277 @@ test("normalizes a code and mints a token for an employee", async () => { assert.deepEqual(await response.json(), { token: "preview-token" }); assert.deepEqual(vi.mocked(jwtStudent).mock.calls[0], ["AB12"]); }); + +test("rate limits repeated requests with unknown student codes", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + + vi.mocked(jwtStudent).mockResolvedValue(null); + + const { POST } = await import("./route"); + + for (let i = 0; i < 5; i++) { + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 404); + } + + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 429); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 5); +}); + +test("does not rate limit valid student scans", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + + vi.mocked(jwtStudent).mockResolvedValue("preview-token"); + + const { POST } = await import("./route"); + + for (let i = 0; i < 10; i++) { + const response = await POST(request({ code: "AB12" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + token: "preview-token", + }); + } + + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 10); +}); + +test("scopes invalid-code rate limits to each employee", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + + vi.mocked(jwtStudent).mockResolvedValue(null); + + const { POST } = await import("./route"); + + for (let i = 0; i < 5; i++) { + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 404); + } + + const limitedResponse = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(limitedResponse.status, 429); + + vi.mocked(getServerSession).mockResolvedValue({ + ...employeeSession, + id: "employee-2", + employee: { + ...employeeSession.employee, + id: "employee-2", + }, + } as never); + + const otherEmployeeResponse = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(otherEmployeeResponse.status, 404); +}); + +test("returns 403 when the employee session has no employee ID", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue({ + ...employeeSession, + employee: { + ...employeeSession.employee, + id: undefined, + }, + } as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + const { POST } = await import("./route"); + + const response = await POST(request({ code: "AB12" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 403); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 0); +}); + +test("resets the invalid-code limit after the window expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-15T10:00:00Z")); + + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + vi.mocked(jwtStudent).mockResolvedValue(null); + + const { POST } = await import("./route"); + + for (let i = 0; i < 5; i++) { + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + assert.equal(response.status, 404); + } + + const blockedResponse = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + assert.equal(blockedResponse.status, 429); + + vi.advanceTimersByTime(60_001); + + const responseAfterExpiry = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(responseAfterExpiry.status, 404); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 6); +}); + +test("returns the expected Retry-After header when rate limited", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-15T10:00:00Z")); + + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + vi.mocked(jwtStudent).mockResolvedValue(null); + + const { POST } = await import("./route"); + + for (let i = 0; i < 5; i++) { + await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + } + + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(response.status, 429); + assert.equal(response.headers.get("Retry-After"), "60"); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 5); +}); + +test("successful scans do not reset the invalid-code count", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + + vi.mocked(jwtStudent) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce("preview-token") + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + const { POST } = await import("./route"); + + for (let i = 0; i < 2; i++) { + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + assert.equal(response.status, 404); + } + + const successfulResponse = await POST(request({ code: "AB12" }), { + params: Promise.resolve({}), + }); + assert.equal(successfulResponse.status, 200); + + for (let i = 0; i < 3; i++) { + const response = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + assert.equal(response.status, 404); + } + + const limitedResponse = await POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }); + + assert.equal(limitedResponse.status, 429); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 6); +}); + +test("allows only the permitted number of concurrent invalid-code requests", async () => { + const getServerSession = ( + await import("@/application/services/sessionService") + ).default; + + vi.mocked(getServerSession).mockResolvedValue(employeeSession as never); + + const { jwtStudent } = + await import("@/application/services/studentTokenService"); + + vi.mocked(jwtStudent).mockResolvedValue(null); + + const { POST } = await import("./route"); + + const responses = await Promise.all( + Array.from({ length: 6 }, () => + POST(request({ code: "ZZ99" }), { + params: Promise.resolve({}), + }) + ) + ); + + assert.equal( + responses.filter((response) => response.status === 404).length, + 5 + ); + assert.equal( + responses.filter((response) => response.status === 429).length, + 1 + ); + assert.equal(vi.mocked(jwtStudent).mock.calls.length, 5); +}); diff --git a/src/app/api/students/token/route.ts b/src/app/api/students/token/route.ts index fba36573..70c47bf5 100644 --- a/src/app/api/students/token/route.ts +++ b/src/app/api/students/token/route.ts @@ -1,17 +1,65 @@ import { NextResponse } from "next/server"; import { defineHandler } from "@/lib/http/server"; +import logger from "@/lib/logger"; +import { createRateLimiter, tooManyRequestsResponse } from "@/lib/rateLimit"; import { jwtStudent } from "@/application/services/studentTokenService"; import { studentTokenSchema } from "@/schemas/studentTokenSchema"; +const invalidCodeLimiter = createRateLimiter({ + windowMs: 60_000, + max: 5, +}); + export const POST = defineHandler({ auth: "employee", schema: studentTokenSchema, - handler: async ({ body }) => { - const token = await jwtStudent(body.code); - if (!token) - return NextResponse.json({ error: "Student not found" }, { status: 404 }); + handler: async ({ body, session }) => { + const employeeId = session?.employee?.id; + + if (!employeeId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const limit = invalidCodeLimiter.reserve(employeeId); + + if (!limit.allowed) { + logger.warn( + { + event: "student_token.rate_limited", + employeeId, + retryAfterMs: limit.retryAfterMs, + }, + "Student token requests rate limited" + ); + + return tooManyRequestsResponse(limit.retryAfterMs); + } + + try { + const token = await jwtStudent(body.code); + + if (!token) { + logger.warn( + { + event: "student_token.invalid_code", + employeeId, + }, + "Invalid student token code" + ); + + return NextResponse.json( + { error: "Student not found" }, + { status: 404 } + ); + } + + limit.release(); - return NextResponse.json({ token }, { status: 200 }); + return NextResponse.json({ token }, { status: 200 }); + } catch (error) { + limit.release(); + throw error; + } }, }); diff --git a/src/lib/rateLimit.test.ts b/src/lib/rateLimit.test.ts index a0a4da1e..ef45e846 100644 --- a/src/lib/rateLimit.test.ts +++ b/src/lib/rateLimit.test.ts @@ -40,3 +40,62 @@ test("resets once the window elapses", () => { assert.equal(limiter.check("ip-1").allowed, true); }); + +test("reserve blocks once the limit is reached", () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 2 }); + + const first = limiter.reserve("employee-1"); + const second = limiter.reserve("employee-1"); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, true); + assert.equal(limiter.reserve("employee-1").allowed, false); +}); + +test("release frees a reservation", () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); + + const reservation = limiter.reserve("employee-1"); + assert.equal(reservation.allowed, true); + assert.equal(limiter.reserve("employee-1").allowed, false); + + reservation.release(); + + assert.equal(limiter.reserve("employee-1").allowed, true); +}); + +test("release is idempotent", () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 2 }); + + const reservation = limiter.reserve("employee-1"); + reservation.release(); + reservation.release(); + + assert.equal(limiter.reserve("employee-1").allowed, true); + assert.equal(limiter.reserve("employee-1").allowed, true); + assert.equal(limiter.reserve("employee-1").allowed, false); +}); + +test("release does not decrement a bucket from a newer window", () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); + + const oldReservation = limiter.reserve("employee-1"); + assert.equal(oldReservation.allowed, true); + + vi.advanceTimersByTime(1001); + + const newReservation = limiter.reserve("employee-1"); + assert.equal(newReservation.allowed, true); + + oldReservation.release(); + + assert.equal(limiter.reserve("employee-1").allowed, false); +}); + +test("reserve tracks separate employees independently", () => { + const limiter = createRateLimiter({ windowMs: 1000, max: 1 }); + + assert.equal(limiter.reserve("employee-1").allowed, true); + assert.equal(limiter.reserve("employee-2").allowed, true); + assert.equal(limiter.reserve("employee-1").allowed, false); +}); diff --git a/src/lib/rateLimit.ts b/src/lib/rateLimit.ts index c3179117..8c72fafa 100644 --- a/src/lib/rateLimit.ts +++ b/src/lib/rateLimit.ts @@ -7,6 +7,15 @@ import { NextResponse } from "next/server"; // or staging load tests show that the boundary burst is material (see #287). type Bucket = { count: number; resetAt: number }; +type RateLimitResult = { + allowed: boolean; + retryAfterMs: number; +}; + +type ReservationResult = RateLimitResult & { + release: () => void; +}; + export function createRateLimiter({ windowMs, max, @@ -17,7 +26,7 @@ export function createRateLimiter({ const buckets = new Map(); // Sweep expired buckets periodically so long-running processes don't - // accumulate one entry per distinct key (IP) forever. + // accumulate one entry per distinct key forever. const sweep = setInterval(() => { const now = Date.now(); for (const [key, bucket] of buckets) @@ -25,22 +34,58 @@ export function createRateLimiter({ }, windowMs); sweep.unref?.(); + function increment(key: string): RateLimitResult { + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { allowed: true, retryAfterMs: 0 }; + } + + if (bucket.count >= max) { + return { + allowed: false, + retryAfterMs: bucket.resetAt - now, + }; + } + + bucket.count += 1; + return { allowed: true, retryAfterMs: 0 }; + } + return { - /** Returns whether `key` is still within its request budget for the current window. */ - check(key: string): { allowed: boolean; retryAfterMs: number } { - const now = Date.now(); - const bucket = buckets.get(key); + /** Counts every accepted request against the current window. */ + check(key: string): RateLimitResult { + return increment(key); + }, - if (!bucket || bucket.resetAt <= now) { - buckets.set(key, { count: 1, resetAt: now + windowMs }); - return { allowed: true, retryAfterMs: 0 }; + /** + * Reserves capacity immediately. Call release() if the operation should + * not count against the limit. The reservation is counted otherwise. + */ + reserve(key: string): ReservationResult { + const result = increment(key); + + if (!result.allowed) { + return { ...result, release: () => {} }; } - if (bucket.count >= max) - return { allowed: false, retryAfterMs: bucket.resetAt - now }; + const bucket = buckets.get(key); + let released = false; + + return { + ...result, + release: () => { + if (released) return; + released = true; - bucket.count += 1; - return { allowed: true, retryAfterMs: 0 }; + // Do not decrement a replacement bucket after the window expires. + if (bucket && buckets.get(key) === bucket) { + bucket.count = Math.max(0, bucket.count - 1); + } + }, + }; }, }; } @@ -51,7 +96,9 @@ export function tooManyRequestsResponse(retryAfterMs: number): NextResponse { { error: "Too many requests" }, { status: 429, - headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) }, + headers: { + "Retry-After": String(Math.ceil(retryAfterMs / 1000)), + }, } ); }