diff --git a/__tests__/lib/j0di3/j0di3-proxy.test.ts b/__tests__/lib/j0di3/j0di3-proxy.test.ts index d08812724..5c92bfbf5 100644 --- a/__tests__/lib/j0di3/j0di3-proxy.test.ts +++ b/__tests__/lib/j0di3/j0di3-proxy.test.ts @@ -15,16 +15,19 @@ vi.mock("axios", () => ({ }, })); -import j0di3 from "@/lib/j0di3-client"; import axios from "axios"; +import j0di3 from "@/lib/j0di3-client"; import { j0di3Proxy } from "@/lib/j0di3-proxy"; const mockJ0di3 = j0di3 as unknown as Mock; const mockIsAxiosError = axios.isAxiosError as Mock; -function createMockReqRes( - overrides: Partial = {} -): { req: NextApiRequest; res: NextApiResponse } { +const VALID_TROOP_ID = "00000000-0000-4000-8000-000000000001"; + +function createMockReqRes(overrides: Partial = {}): { + req: NextApiRequest; + res: NextApiResponse; +} { const req = { method: "POST", body: { question: "What is React?" }, @@ -34,7 +37,7 @@ function createMockReqRes( name: "Test", email: "test@test.com", role: "STUDENT", - troopId: "troop-uuid-123", + troopId: VALID_TROOP_ID, }, ...overrides, } as unknown as NextApiRequest; @@ -59,7 +62,7 @@ describe("j0di3Proxy", () => { expect(mockJ0di3).toHaveBeenCalledWith({ method: "POST", url: "/api/v1/learning/explain", - data: { question: "What is React?", troop_id: "troop-uuid-123" }, + data: { question: "What is React?", troop_id: VALID_TROOP_ID }, params: undefined, }); expect(res.json).toHaveBeenCalledWith({ response: "React is a library" }); @@ -77,7 +80,7 @@ describe("j0di3Proxy", () => { method: "GET", url: "/api/v1/challenges/recommended", data: undefined, - params: expect.objectContaining({ troop_id: "troop-uuid-123" }), + params: expect.objectContaining({ troop_id: VALID_TROOP_ID }), }); }); @@ -110,6 +113,23 @@ describe("j0di3Proxy", () => { ); }); + it("returns 400 when troopId is not a valid UUID", async () => { + const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const handler = j0di3Proxy("POST", "/api/v1/learning/explain"); + const { req, res } = createMockReqRes(); + (req as any).user.troopId = "not-a-uuid"; + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining("troop profile is invalid") }) + ); + expect(mockJ0di3).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + it("forwards J0dI3 error status and message on axios error", async () => { const axiosError = { response: { diff --git a/src/lib/j0di3-proxy.ts b/src/lib/j0di3-proxy.ts index 5e55b7035..6c2c6900b 100644 --- a/src/lib/j0di3-proxy.ts +++ b/src/lib/j0di3-proxy.ts @@ -1,14 +1,22 @@ import axios from "axios"; import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth, type AuthenticatedRequest } from "@/lib/rbac"; import j0di3 from "@/lib/j0di3-client"; +import { type AuthenticatedRequest, requireAuth } from "@/lib/rbac"; type Method = "GET" | "POST" | "PATCH" | "DELETE"; -export function j0di3Proxy( - method: Method, - path: string | ((req: NextApiRequest) => string) -) { +// J0dI3 issues UUIDs for troop IDs (see ensure-troop.ts). Reject anything +// that isn't shaped like one before making the external call, so a stale +// session or bad migration surfaces as a clear user-actionable error +// instead of an opaque 4xx/5xx from the backend. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function redactTroopId(value: string): string { + if (value.length <= 8) return `${value.slice(0, 2)}…`; + return `${value.slice(0, 4)}…${value.slice(-4)}`; +} + +export function j0di3Proxy(method: Method, path: string | ((req: NextApiRequest) => string)) { return requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => { const url = typeof path === "function" ? path(req) : path; const troopId = req.user!.troopId; @@ -19,9 +27,19 @@ export function j0di3Proxy( .json({ error: "No J0dI3 troop profile linked. Please sign out and back in." }); } + if (!UUID_RE.test(troopId)) { + console.warn( + "[j0di3-proxy] Rejected malformed troopId for user", + req.user!.id, + redactTroopId(troopId) + ); + return res.status(400).json({ + error: "Your troop profile is invalid. Please sign out and sign back in to refresh it.", + }); + } + try { - const body = - method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; + const body = method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; const { data } = await j0di3({ method, @@ -30,12 +48,14 @@ export function j0di3Proxy( params: method === "GET" ? { ...req.query, troop_id: troopId } : undefined, }); - res.json(data); } catch (error: unknown) { if (axios.isAxiosError(error) && error.response) { const status = error.response.status; - const message = error.response.data?.detail || error.response.data?.error || "J0dI3 request failed"; + const message = + error.response.data?.detail || + error.response.data?.error || + "J0dI3 request failed"; return res.status(status).json({ error: message }); } console.error("[j0di3-proxy] Unexpected error:", error);