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
278 changes: 278 additions & 0 deletions src/app/api/students/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
58 changes: 53 additions & 5 deletions src/app/api/students/token/route.ts
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reserve runs before the student lookup, so it also consumes all five slots for in-flight valid scans. Six simultaneous valid requests for one employee make the sixth return 429 without calling jwtStudent; the first five only release their reservations after their lookups resolve. This breaks the invalid-code-only limiter behavior and is not covered by the sequential valid-scan test. Change the approach so valid concurrent scans are not rejected, while retaining atomic invalid-attempt enforcement.


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;
}
},
});
Loading
Loading