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
34 changes: 27 additions & 7 deletions __tests__/lib/j0di3/j0di3-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextApiRequest> = {}
): { req: NextApiRequest; res: NextApiResponse } {
const VALID_TROOP_ID = "00000000-0000-4000-8000-000000000001";

function createMockReqRes(overrides: Partial<NextApiRequest> = {}): {
req: NextApiRequest;
res: NextApiResponse;
} {
const req = {
method: "POST",
body: { question: "What is React?" },
Expand All @@ -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;
Expand All @@ -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" });
Expand All @@ -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 }),
});
});

Expand Down Expand Up @@ -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: {
Expand Down
38 changes: 29 additions & 9 deletions src/lib/j0di3-proxy.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -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);
Expand Down
Loading