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
40 changes: 36 additions & 4 deletions __tests__/lib/j0di3/j0di3-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ 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;
const mockIsAxiosError = axios.isAxiosError as unknown as Mock;

function createMockReqRes(overrides: Partial<NextApiRequest> = {}): {
req: NextApiRequest;
Expand All @@ -35,7 +35,7 @@ function createMockReqRes(overrides: Partial<NextApiRequest> = {}): {
name: "Test",
email: "test@test.com",
role: "STUDENT",
troopId: "troop-uuid-123",
troopId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
troopToken: "troop-token-abc",
},
...overrides,
Expand All @@ -62,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: "3f2504e0-4f89-41d3-9a0c-0305e82c3301" },
params: undefined,
headers: { "X-Troop-Token": "troop-token-abc" },
});
Expand All @@ -81,7 +81,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: "3f2504e0-4f89-41d3-9a0c-0305e82c3301" }),
headers: { "X-Troop-Token": "troop-token-abc" },
});
});
Expand Down Expand Up @@ -153,6 +153,38 @@ describe("j0di3Proxy", () => {
);
});

it("returns 400 without calling upstream when troopId is malformed", 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({
error: "Invalid troop profile. Please sign out and sign back in.",
});
expect(mockJ0di3).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});

it("logs the rejected troopId redacted, never the full value", 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 = "bogus-troop-id-value";

await handler(req, res);

expect(consoleSpy).toHaveBeenCalledTimes(1);
const logged = String(consoleSpy.mock.calls[0][0]);
expect(logged).toContain("bogu");
expect(logged).toContain("length 20");
expect(logged).not.toContain("bogus-troop-id-value");
consoleSpy.mockRestore();
});

it("returns 400 when user has troopId but no troopToken", async () => {
const handler = j0di3Proxy("POST", "/api/v1/learning/explain");
const { req, res } = createMockReqRes();
Expand Down
8 changes: 4 additions & 4 deletions __tests__/pages/api/j0di3/challenges.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => vi.fn()),
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) => vi.fn()),
}));

vi.mock("@/lib/rbac", () => ({
requireAuth: vi.fn((handler) => handler),
}));

import { j0di3Proxy } from "@/lib/j0di3-proxy";

describe("J0dI3 Challenges API routes", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => vi.fn()),
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) =>
vi.fn()
),
}));
});

Expand Down
9 changes: 5 additions & 4 deletions __tests__/pages/api/j0di3/coding.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => vi.fn()),
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) => vi.fn()),
}));

import { j0di3Proxy } from "@/lib/j0di3-proxy";

describe("J0dI3 Coding API routes", () => {
const routes = [
{ module: "@/pages/api/j0di3/coding/review", endpoint: "/api/v1/coding/review" },
{ module: "@/pages/api/j0di3/coding/refactor", endpoint: "/api/v1/coding/refactor" },
{ module: "@/pages/api/j0di3/coding/explain", endpoint: "/api/v1/coding/explain" },
{ module: "@/pages/api/j0di3/coding/generate", endpoint: "/api/v1/coding/generate" },
{ module: "@/pages/api/j0di3/coding/architecture", endpoint: "/api/v1/coding/architecture" },
{
module: "@/pages/api/j0di3/coding/architecture",
endpoint: "/api/v1/coding/architecture",
},
];

for (const { module, endpoint } of routes) {
Expand Down
14 changes: 11 additions & 3 deletions __tests__/pages/api/j0di3/jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ describe("J0dI3 Jobs API routes", () => {
beforeEach(() => {
vi.resetModules();
vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => vi.fn()),
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) =>
vi.fn()
),
}));
});

Expand All @@ -11,8 +13,14 @@ describe("J0dI3 Jobs API routes", () => {
{ module: "@/pages/api/j0di3/jobs/resume/tailor", endpoint: "/api/v1/jobs/resume/tailor" },
{ module: "@/pages/api/j0di3/jobs/match", endpoint: "/api/v1/jobs/match" },
{ module: "@/pages/api/j0di3/jobs/apply-coach", endpoint: "/api/v1/jobs/apply-coach" },
{ module: "@/pages/api/j0di3/jobs/interview/start", endpoint: "/api/v1/jobs/interview/start" },
{ module: "@/pages/api/j0di3/jobs/offer/evaluate", endpoint: "/api/v1/jobs/offer/evaluate" },
{
module: "@/pages/api/j0di3/jobs/interview/start",
endpoint: "/api/v1/jobs/interview/start",
},
{
module: "@/pages/api/j0di3/jobs/offer/evaluate",
endpoint: "/api/v1/jobs/offer/evaluate",
},
];

for (const { module, endpoint } of postRoutes) {
Expand Down
9 changes: 2 additions & 7 deletions __tests__/pages/api/j0di3/learning.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import type { Mock } from "vitest";

vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => {
const handler = vi.fn();
handler._method = _method;
handler._path = _path;
return handler;
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) => {
return Object.assign(vi.fn(), { _method, _path });
}),
}));

Expand Down
6 changes: 3 additions & 3 deletions __tests__/pages/api/j0di3/troops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ vi.mock("axios", () => ({
},
}));

import j0di3 from "@/lib/j0di3-client";

function createRes() {
return {
status: vi.fn().mockReturnThis(),
Expand Down Expand Up @@ -108,7 +106,9 @@ describe("troops/dashboard API route", () => {
it("wires to GET with dynamic troop path", async () => {
vi.resetModules();
vi.mock("@/lib/j0di3-proxy", () => ({
j0di3Proxy: vi.fn((_method: string, _path: string | Function) => vi.fn()),
j0di3Proxy: vi.fn((_method: string, _path: string | ((req: unknown) => string)) =>
vi.fn()
),
}));
vi.mock("@/lib/rbac", () => ({
requireAuth: vi.fn((h) => h),
Expand Down
14 changes: 14 additions & 0 deletions src/lib/j0di3-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { type AuthenticatedRequest, requireAuth, requireRole } from "@/lib/rbac"

type Method = "GET" | "POST" | "PATCH" | "DELETE";

// J0dI3 troop IDs are UUIDs (see prisma/schema.prisma User.troopId). Accept any
// UUID version rather than over-restricting to v4.
const TROOP_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

interface ProxyOptions {
injectTroopId?: boolean;
}
Expand All @@ -25,6 +29,16 @@ async function dispatch(
.json({ error: "No J0dI3 troop profile linked. Please sign out and back in." });
}

if (injectTroopId && troopId && !TROOP_ID_PATTERN.test(troopId)) {
// Log redacted so the origin of the bad value can be investigated.
console.warn(
`[j0di3-proxy] Rejected malformed troopId: ${troopId.slice(0, 4)}… (length ${troopId.length})`
);
return res
.status(400)
.json({ error: "Invalid troop profile. Please sign out and sign back in." });
}

if (injectTroopId && !troopToken) {
return res.status(400).json({
error: "Missing J0dI3 troop access token. Please sign out and back in to refresh.",
Expand Down
Loading