diff --git a/eslint.config.js b/eslint.config.js index 2af2f2e0..3dca070a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -105,6 +105,20 @@ export default tseslint.config( message: "Do not directly call `invalidateQueries`. Instead, use the `invalidateQueries` helper function.", }, + { + selector: [ + "CallExpression[callee.object.name='http'][callee.property.name='all'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='head'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='get'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='post'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='put'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='delete'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='patch'] > Literal:first-child", + "CallExpression[callee.object.name='http'][callee.property.name='options'] > Literal:first-child", + ].join(", "), + message: + "Do not pass a string as the first argument to methods on Mock Service Worker's `http`. Use the `mswEndpoint` helper function instead, which provides type-safe routes based on the OpenAPI spec and the API base URL.", + }, ], "no-restricted-imports": [ "error", diff --git a/src/features/alerts/components/__tests__/alerts-summary-malicious-pkg.test.tsx b/src/features/alerts/components/__tests__/alerts-summary-malicious-pkg.test.tsx index ed1d728a..44a4c7b1 100644 --- a/src/features/alerts/components/__tests__/alerts-summary-malicious-pkg.test.tsx +++ b/src/features/alerts/components/__tests__/alerts-summary-malicious-pkg.test.tsx @@ -3,12 +3,14 @@ import { test } from "vitest"; import { http, HttpResponse } from "msw"; import { render, waitFor } from "@/lib/test-utils"; import { AlertsSummaryMaliciousPkg } from "../alerts-summary-malicious-pkg"; -import { makeMockAlert } from "../../mocks/alert.mock"; + +import { mswEndpoint } from "@/test/msw-endpoint"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; test("shows correct count when there is a malicious alert", async () => { server.use( - http.get("*/api/v1/workspaces/:name/alerts", () => { - return HttpResponse.json([makeMockAlert({ type: "malicious" })]); + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { + return HttpResponse.json([mockAlert({ type: "malicious" })]); }), ); @@ -21,8 +23,8 @@ test("shows correct count when there is a malicious alert", async () => { test("shows correct count when there is no malicious alert", async () => { server.use( - http.get("*/api/v1/workspaces/:name/alerts", () => { - return HttpResponse.json([makeMockAlert({ type: "secret" })]); + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { + return HttpResponse.json([mockAlert({ type: "secret" })]); }), ); diff --git a/src/features/alerts/components/__tests__/alerts-summary-secrets.test.tsx b/src/features/alerts/components/__tests__/alerts-summary-secrets.test.tsx index 5a99655b..9e3c2484 100644 --- a/src/features/alerts/components/__tests__/alerts-summary-secrets.test.tsx +++ b/src/features/alerts/components/__tests__/alerts-summary-secrets.test.tsx @@ -4,12 +4,13 @@ import { http, HttpResponse } from "msw"; import { render, waitFor } from "@/lib/test-utils"; import { AlertsSummaryMaliciousSecrets } from "../alerts-summary-secrets"; -import { makeMockAlert } from "../../mocks/alert.mock"; +import { mswEndpoint } from "@/test/msw-endpoint"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; test("shows correct count when there is a secret alert", async () => { server.use( - http.get("*/api/v1/workspaces/:name/alerts", () => { - return HttpResponse.json([makeMockAlert({ type: "secret" })]); + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { + return HttpResponse.json([mockAlert({ type: "secret" })]); }), ); @@ -22,8 +23,8 @@ test("shows correct count when there is a secret alert", async () => { test("shows correct count when there is no malicious alert", async () => { server.use( - http.get("*/api/v1/workspaces/:name/alerts", () => { - return HttpResponse.json([makeMockAlert({ type: "malicious" })]); + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { + return HttpResponse.json([mockAlert({ type: "malicious" })]); }), ); diff --git a/src/features/alerts/components/__tests__/alerts-summary-workspace-token-usage.test.tsx b/src/features/alerts/components/__tests__/alerts-summary-workspace-token-usage.test.tsx index 56466a27..02138190 100644 --- a/src/features/alerts/components/__tests__/alerts-summary-workspace-token-usage.test.tsx +++ b/src/features/alerts/components/__tests__/alerts-summary-workspace-token-usage.test.tsx @@ -4,14 +4,19 @@ import { http, HttpResponse } from "msw"; import { render, waitFor } from "@/lib/test-utils"; import { AlertsSummaryWorkspaceTokenUsage } from "../alerts-summary-workspace-token-usage"; -import { TOKEN_USAGE_AGG } from "../../mocks/token-usage.mock"; + import { formatNumberCompact } from "@/lib/format-number"; +import { mswEndpoint } from "@/test/msw-endpoint"; +import { TOKEN_USAGE_AGG } from "@/mocks/msw/mockers/token-usage.mock"; test("shows correct count when there is token usage", async () => { server.use( - http.get("*/api/v1/workspaces/:name/token-usage", () => { - return HttpResponse.json(TOKEN_USAGE_AGG); - }), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/token-usage"), + () => { + return HttpResponse.json(TOKEN_USAGE_AGG); + }, + ), ); const { getByTestId } = render(); @@ -28,9 +33,12 @@ test("shows correct count when there is token usage", async () => { test("shows correct count when there is no token usage", async () => { server.use( - http.get("*/api/v1/workspaces/:name/token-usage", () => { - return HttpResponse.json({}); - }), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/token-usage"), + () => { + return HttpResponse.json({}); + }, + ), ); const { getByTestId } = render(); diff --git a/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx index c6ce31f4..1db06c0a 100644 --- a/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx +++ b/src/features/alerts/components/__tests__/table-alerts.empty-state.test.tsx @@ -4,10 +4,12 @@ import { server } from "@/mocks/msw/node"; import { emptyStateStrings } from "../../constants/strings"; import { useSearchParams } from "react-router-dom"; import { delay, http, HttpHandler, HttpResponse } from "msw"; -import { makeMockAlert } from "../../mocks/alert.mock"; + import { AlertsFilterView } from "../../hooks/use-alerts-filter-search-params"; import { TableAlerts } from "../table-alerts"; import { hrefs } from "@/lib/hrefs"; +import { mswEndpoint } from "@/test/msw-endpoint"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; enum IllustrationTestId { ALERT = "illustration-alert", @@ -78,7 +80,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: "Loading state", handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { delay("infinite"); }), ], @@ -96,7 +98,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: "Only 1 workspace, no alerts", handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json({ workspaces: [ { @@ -106,12 +108,12 @@ const TEST_CASES: TestCase[] = [ ], }); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [], }); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([]); }), ], @@ -135,7 +137,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: "No search results", handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json({ workspaces: [ { @@ -145,16 +147,14 @@ const TEST_CASES: TestCase[] = [ ], }); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [], }); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( - Array.from({ length: 10 }, () => - makeMockAlert({ type: "malicious" }), - ), + Array.from({ length: 10 }, () => mockAlert({ type: "malicious" })), ); }), ], @@ -174,7 +174,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: "No alerts, multiple workspaces", handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json({ workspaces: [ { @@ -188,12 +188,12 @@ const TEST_CASES: TestCase[] = [ ], }); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [], }); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([]); }), ], @@ -217,7 +217,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: 'Has alerts, view is "malicious"', handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json({ workspaces: [ { @@ -231,16 +231,14 @@ const TEST_CASES: TestCase[] = [ ], }); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [], }); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( - Array.from({ length: 10 }).map(() => - makeMockAlert({ type: "secret" }), - ), + Array.from({ length: 10 }).map(() => mockAlert({ type: "secret" })), ); }), ], @@ -258,7 +256,7 @@ const TEST_CASES: TestCase[] = [ { testDescription: 'Has alerts, view is "secret"', handlers: [ - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json({ workspaces: [ { @@ -272,15 +270,15 @@ const TEST_CASES: TestCase[] = [ ], }); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [], }); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( Array.from({ length: 10 }).map(() => - makeMockAlert({ type: "malicious" }), + mockAlert({ type: "malicious" }), ), ); }), diff --git a/src/features/alerts/components/__tests__/table-alerts.test.tsx b/src/features/alerts/components/__tests__/table-alerts.test.tsx index 905f31af..404932dc 100644 --- a/src/features/alerts/components/__tests__/table-alerts.test.tsx +++ b/src/features/alerts/components/__tests__/table-alerts.test.tsx @@ -3,9 +3,11 @@ import { TableAlerts } from "../table-alerts"; import { render, waitFor } from "@/lib/test-utils"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; -import { makeMockAlert } from "../../mocks/alert.mock"; -import { TOKEN_USAGE_AGG } from "../../mocks/token-usage.mock"; import { formatNumberCompact } from "@/lib/format-number"; +import { mswEndpoint } from "@/test/msw-endpoint"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; +import { TOKEN_USAGE_AGG } from "@/mocks/msw/mockers/token-usage.mock"; +import { mockConversation } from "@/mocks/msw/mockers/conversation.mock"; vi.mock("@untitled-ui/icons-react", async () => { const original = await vi.importActual< @@ -28,9 +30,14 @@ const OUTPUT_TOKENS = test("renders token usage cell correctly", async () => { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([ - makeMockAlert({ token_usage: true, type: "malicious" }), + { + ...mockAlert({ type: "malicious" }), + conversation: mockConversation({ + withTokenUsage: true, + }), + }, ]); }), ); @@ -53,9 +60,14 @@ test("renders token usage cell correctly", async () => { test("renders N/A when token usage is missing", async () => { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([ - makeMockAlert({ token_usage: false, type: "malicious" }), + { + ...mockAlert({ type: "malicious" }), + conversation: mockConversation({ + withTokenUsage: false, + }), + }, ]); }), ); diff --git a/src/features/alerts/components/__tests__/tabs-alerts.test.tsx b/src/features/alerts/components/__tests__/tabs-alerts.test.tsx index 913647d4..da4cd1d6 100644 --- a/src/features/alerts/components/__tests__/tabs-alerts.test.tsx +++ b/src/features/alerts/components/__tests__/tabs-alerts.test.tsx @@ -1,18 +1,18 @@ import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; -import { makeMockAlert } from "../../mocks/alert.mock"; + import { render, waitFor } from "@/lib/test-utils"; import { TabsAlerts } from "../tabs-alerts"; +import { mswEndpoint } from "@/test/msw-endpoint"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; test("shows correct count of all packages", async () => { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([ + ...Array.from({ length: 13 }).map(() => mockAlert({ type: "secret" })), ...Array.from({ length: 13 }).map(() => - makeMockAlert({ type: "secret" }), - ), - ...Array.from({ length: 13 }).map(() => - makeMockAlert({ type: "malicious" }), + mockAlert({ type: "malicious" }), ), ]); }), @@ -31,11 +31,9 @@ test("shows correct count of all packages", async () => { test("shows correct count of malicious packages", async () => { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( - Array.from({ length: 13 }).map(() => - makeMockAlert({ type: "malicious" }), - ), + Array.from({ length: 13 }).map(() => mockAlert({ type: "malicious" })), ); }), ); @@ -53,9 +51,9 @@ test("shows correct count of malicious packages", async () => { test("shows correct count of secret packages", async () => { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( - Array.from({ length: 13 }).map(() => makeMockAlert({ type: "secret" })), + Array.from({ length: 13 }).map(() => mockAlert({ type: "secret" })), ); }), ); diff --git a/src/features/alerts/lib/__tests__/is-alert-malicious.test.ts b/src/features/alerts/lib/__tests__/is-alert-malicious.test.ts index b41a9505..dfcd8b3f 100644 --- a/src/features/alerts/lib/__tests__/is-alert-malicious.test.ts +++ b/src/features/alerts/lib/__tests__/is-alert-malicious.test.ts @@ -1,11 +1,11 @@ import { test, expect } from "vitest"; import { isAlertMalicious } from "../is-alert-malicious"; -import { makeMockAlert } from "../../mocks/alert.mock"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; test("matches malicious alert", () => { - expect(isAlertMalicious(makeMockAlert({ type: "malicious" }))).toBe(true); + expect(isAlertMalicious(mockAlert({ type: "malicious" }))).toBe(true); }); test("doesn't match secret", () => { - expect(isAlertMalicious(makeMockAlert({ type: "secret" }))).toBe(false); + expect(isAlertMalicious(mockAlert({ type: "secret" }))).toBe(false); }); diff --git a/src/features/alerts/lib/__tests__/is-alert-secret.test.ts b/src/features/alerts/lib/__tests__/is-alert-secret.test.ts index 88d32bfa..16f70071 100644 --- a/src/features/alerts/lib/__tests__/is-alert-secret.test.ts +++ b/src/features/alerts/lib/__tests__/is-alert-secret.test.ts @@ -1,11 +1,11 @@ import { test, expect } from "vitest"; import { isAlertSecret } from "../is-alert-secret"; -import { makeMockAlert } from "../../mocks/alert.mock"; +import { mockAlert } from "@/mocks/msw/mockers/alert.mock"; test("matches secret alert", () => { - expect(isAlertSecret(makeMockAlert({ type: "secret" }))).toBe(true); + expect(isAlertSecret(mockAlert({ type: "secret" }))).toBe(true); }); test("doesn't match malicious", () => { - expect(isAlertSecret(makeMockAlert({ type: "malicious" }))).toBe(false); + expect(isAlertSecret(mockAlert({ type: "malicious" }))).toBe(false); }); diff --git a/src/features/alerts/lib/is-alert-malicious.ts b/src/features/alerts/lib/is-alert-malicious.ts index 2c9e10ed..457c36e4 100644 --- a/src/features/alerts/lib/is-alert-malicious.ts +++ b/src/features/alerts/lib/is-alert-malicious.ts @@ -1,7 +1,7 @@ -import { AlertConversation } from "@/api/generated"; +import { Alert, AlertConversation } from "@/api/generated"; export function isAlertMalicious( - alert: AlertConversation | null, + alert: Alert | AlertConversation | null, ): alert is AlertConversation { return ( alert?.trigger_category === "critical" && diff --git a/src/features/alerts/lib/is-alert-secret.ts b/src/features/alerts/lib/is-alert-secret.ts index 9e7fd87a..7acc6612 100644 --- a/src/features/alerts/lib/is-alert-secret.ts +++ b/src/features/alerts/lib/is-alert-secret.ts @@ -1,6 +1,6 @@ -import { V1GetWorkspaceAlertsResponse } from "@/api/generated"; +import { Alert, AlertConversation } from "@/api/generated"; -export function isAlertSecret(alert: V1GetWorkspaceAlertsResponse[number]) { +export function isAlertSecret(alert: Alert | AlertConversation | null) { return ( alert?.trigger_category === "critical" && alert.trigger_type === "codegate-secrets" diff --git a/src/features/alerts/mocks/alert.mock.ts b/src/features/alerts/mocks/alert.mock.ts deleted file mode 100644 index 945b6aef..00000000 --- a/src/features/alerts/mocks/alert.mock.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - AlertConversation, - QuestionType, - TokenUsageAggregate, -} from "@/api/generated"; -import { faker } from "@faker-js/faker"; -import { TOKEN_USAGE_AGG } from "./token-usage.mock"; - -export const ALERT_SECRET_FIELDS = { - trigger_string: "foo", - trigger_type: "codegate-secrets", -} satisfies Pick; - -export const ALERT_MALICIOUS_FIELDS = { - trigger_string: { - name: "invokehttp", - type: "pypi", - status: "malicious", - description: "Python HTTP for Humans.", - }, - trigger_type: "codegate-context-retriever", -} satisfies Pick; - -const getBaseAlert = ({ - timestamp, - token_usage_agg, -}: { - timestamp: string; - token_usage_agg: TokenUsageAggregate | null; -}): Omit => ({ - conversation: { - question_answers: [ - { - question: { - message: "foo", - timestamp: timestamp, - message_id: faker.string.uuid(), - }, - answer: { - message: "bar", - timestamp: timestamp, - message_id: faker.string.uuid(), - }, - }, - ], - provider: "anthropic", - type: QuestionType.CHAT, - chat_id: faker.string.uuid(), - conversation_timestamp: timestamp, - token_usage_agg, - }, - alert_id: faker.string.uuid(), - code_snippet: null, - trigger_category: "critical", - timestamp: timestamp, -}); - -export const makeMockAlert = ({ - token_usage = false, - type, -}: { - token_usage?: boolean; - type: "secret" | "malicious"; -}): AlertConversation => { - const timestamp = faker.date.recent().toUTCString(); - - const base: Omit = - getBaseAlert({ - timestamp, - token_usage_agg: token_usage ? TOKEN_USAGE_AGG : null, - }); - - switch (type) { - case "malicious": { - const result: AlertConversation = { - ...base, - ...ALERT_MALICIOUS_FIELDS, - }; - - return result; - } - case "secret": { - const result: AlertConversation = { - ...base, - ...ALERT_SECRET_FIELDS, - }; - - return result; - } - } -}; diff --git a/src/features/header/components/__tests__/header-status-menu.test.tsx b/src/features/header/components/__tests__/header-status-menu.test.tsx index 154109e0..a957694c 100644 --- a/src/features/header/components/__tests__/header-status-menu.test.tsx +++ b/src/features/header/components/__tests__/header-status-menu.test.tsx @@ -5,13 +5,16 @@ import { expect } from "vitest"; import { render, waitFor } from "@/lib/test-utils"; import { HeaderStatusMenu } from "../header-status-menu"; import userEvent from "@testing-library/user-event"; +import { mswEndpoint } from "@/test/msw-endpoint"; const renderComponent = () => render(); describe("CardCodegateStatus", () => { test("renders 'healthy' state", async () => { server.use( - http.get("*/health", () => HttpResponse.json({ status: "healthy" })), + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: "healthy" }), + ), ); const { getByRole } = renderComponent(); @@ -22,7 +25,11 @@ describe("CardCodegateStatus", () => { }); test("renders 'unhealthy' state", async () => { - server.use(http.get("*/health", () => HttpResponse.json({ status: null }))); + server.use( + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: null }), + ), + ); const { getByRole } = renderComponent(); @@ -32,7 +39,7 @@ describe("CardCodegateStatus", () => { }); test("renders 'error' state when health check request fails", async () => { - server.use(http.get("*/health", () => HttpResponse.error())); + server.use(http.get(mswEndpoint("/health"), () => HttpResponse.error())); const { getByRole } = renderComponent(); @@ -43,8 +50,10 @@ describe("CardCodegateStatus", () => { test("renders 'error' state when version check request fails", async () => { server.use( - http.get("*/health", () => HttpResponse.json({ status: "healthy" })), - http.get("*/api/v1/version", () => HttpResponse.error()), + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: "healthy" }), + ), + http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.error()), ); const { getByRole } = renderComponent(); @@ -56,8 +65,10 @@ describe("CardCodegateStatus", () => { test("renders 'up to date' state", async () => { server.use( - http.get("*/health", () => HttpResponse.json({ status: "healthy" })), - http.get("*/api/v1/version", () => + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: "healthy" }), + ), + http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ current_version: "foo", latest_version: "foo", @@ -83,8 +94,10 @@ describe("CardCodegateStatus", () => { test("renders 'update available' state", async () => { server.use( - http.get("*/health", () => HttpResponse.json({ status: "healthy" })), - http.get("*/api/v1/version", () => + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: "healthy" }), + ), + http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ current_version: "foo", latest_version: "bar", @@ -115,8 +128,10 @@ describe("CardCodegateStatus", () => { test("renders 'version check error' state", async () => { server.use( - http.get("*/health", () => HttpResponse.json({ status: "healthy" })), - http.get("*/api/v1/version", () => + http.get(mswEndpoint("/health"), () => + HttpResponse.json({ status: "healthy" }), + ), + http.get(mswEndpoint("/api/v1/version"), () => HttpResponse.json({ current_version: "foo", latest_version: "bar", diff --git a/src/features/workspace/components/__tests__/archive-workspace.test.tsx b/src/features/workspace/components/__tests__/archive-workspace.test.tsx index f27d6183..9a6b32de 100644 --- a/src/features/workspace/components/__tests__/archive-workspace.test.tsx +++ b/src/features/workspace/components/__tests__/archive-workspace.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { waitFor } from "@testing-library/react"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; +import { mswEndpoint } from "@/test/msw-endpoint"; test("has correct buttons when not archived", async () => { const { getByRole, queryByRole } = render( @@ -66,7 +67,7 @@ test("can permanently delete archived workspace", async () => { test("can't archive active workspace", async () => { server.use( - http.get("*/api/v1/workspaces/active", () => + http.get(mswEndpoint("/api/v1/workspaces/active"), () => HttpResponse.json({ workspaces: [ { diff --git a/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx b/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx index cf63e5fb..b9d83c1f 100644 --- a/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-custom-instructions.test.tsx @@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; import { WorkspaceCustomInstructions } from "../workspace-custom-instructions"; +import { mswEndpoint } from "@/test/msw-endpoint"; vi.mock("@monaco-editor/react", () => { const FakeEditor = vi.fn((props) => { @@ -26,9 +27,12 @@ const renderComponent = () => test("can update custom instructions", async () => { server.use( - http.get("*/api/v1/workspaces/:name/custom-instructions", () => { - return HttpResponse.json({ prompt: "initial prompt from server" }); - }), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), + () => { + return HttpResponse.json({ prompt: "initial prompt from server" }); + }, + ), ); const { getByRole, getByText } = renderComponent(); @@ -45,9 +49,12 @@ test("can update custom instructions", async () => { expect(input).toHaveTextContent("new prompt from test"); server.use( - http.get("*/api/v1/workspaces/:name/custom-instructions", () => { - return HttpResponse.json({ prompt: "new prompt from test" }); - }), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), + () => { + return HttpResponse.json({ prompt: "new prompt from test" }); + }, + ), ); await userEvent.click(getByRole("button", { name: /Save/i })); diff --git a/src/features/workspace/components/__tests__/workspace-name.test.tsx b/src/features/workspace/components/__tests__/workspace-name.test.tsx index d92b25b4..84bfc93f 100644 --- a/src/features/workspace/components/__tests__/workspace-name.test.tsx +++ b/src/features/workspace/components/__tests__/workspace-name.test.tsx @@ -4,6 +4,7 @@ import { render, waitFor } from "@/lib/test-utils"; import userEvent from "@testing-library/user-event"; import { server } from "@/mocks/msw/node"; import { http, HttpResponse } from "msw"; +import { mswEndpoint } from "@/test/msw-endpoint"; test("can rename workspace", async () => { const { getByRole, getByText } = render( @@ -34,7 +35,7 @@ test("can't rename archived workspace", async () => { test("can't rename active workspace", async () => { server.use( - http.get("*/api/v1/workspaces/active", () => + http.get(mswEndpoint("/api/v1/workspaces/active"), () => HttpResponse.json({ workspaces: [ { diff --git a/src/mocks/msw/handlers.ts b/src/mocks/msw/handlers.ts index 2a4974cb..a852dc8f 100644 --- a/src/mocks/msw/handlers.ts +++ b/src/mocks/msw/handlers.ts @@ -1,22 +1,25 @@ import { http, HttpResponse } from "msw"; -import mockedPrompts from "@/mocks/msw/fixtures/GET_MESSAGES.json"; import mockedAlerts from "@/mocks/msw/fixtures/GET_ALERTS.json"; import mockedWorkspaces from "@/mocks/msw/fixtures/GET_WORKSPACES.json"; import mockedProviders from "@/mocks/msw/fixtures/GET_PROVIDERS.json"; import mockedProvidersModels from "@/mocks/msw/fixtures/GET_PROVIDERS_MODELS.json"; import { ProviderType } from "@/api/generated"; +import { mockConversation } from "./mockers/conversation.mock"; +import { mswEndpoint } from "@/test/msw-endpoint"; export const handlers = [ - http.get("*/health", () => + http.get(mswEndpoint("/health"), () => HttpResponse.json({ current_version: "foo", latest_version: "bar", is_latest: false, error: null, - }), + }) ), - http.get("*/api/v1/version", () => HttpResponse.json({ status: "healthy" })), - http.get("*/api/v1/workspaces/active", () => + http.get(mswEndpoint("/api/v1/version"), () => + HttpResponse.json({ status: "healthy" }) + ), + http.get(mswEndpoint("/api/v1/workspaces/active"), () => HttpResponse.json({ workspaces: [ { @@ -25,18 +28,20 @@ export const handlers = [ last_updated: new Date(Date.now()).toISOString(), }, ], - }), + }) ), - http.get("*/api/v1/workspaces/:name/messages", () => { - return HttpResponse.json(mockedPrompts); + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/messages"), () => { + return HttpResponse.json( + Array.from({ length: 10 }).map(() => mockConversation()) + ); }), - http.get("*/api/v1/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json(mockedAlerts); }), - http.get("*/api/v1/workspaces", () => { + http.get(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json(mockedWorkspaces); }), - http.get("*/api/v1/workspaces/archive", () => { + http.get(mswEndpoint("/api/v1/workspaces/archive"), () => { return HttpResponse.json({ workspaces: [ { @@ -46,55 +51,61 @@ export const handlers = [ ], }); }), - http.post("*/api/v1/workspaces", () => { + http.post(mswEndpoint("/api/v1/workspaces"), () => { return HttpResponse.json(mockedWorkspaces); }), http.post( - "*/api/v1/workspaces/active", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/workspaces/active"), + () => new HttpResponse(null, { status: 204 }) ), http.post( - "*/api/v1/workspaces/archive/:workspace_name/recover", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/workspaces/archive/:workspace_name/recover"), + () => new HttpResponse(null, { status: 204 }) ), http.delete( - "*/api/v1/workspaces/:name", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/workspaces/:workspace_name"), + () => new HttpResponse(null, { status: 204 }) ), http.delete( - "*/api/v1/workspaces/archive/:name", - () => new HttpResponse(null, { status: 204 }), - ), - http.get("*/api/v1/workspaces/:name/custom-instructions", () => { - return HttpResponse.json({ prompt: "foo" }); - }), - http.get("*/api/v1/workspaces/:name/token-usage", () => { - return HttpResponse.json({ - tokens_by_model: { - "claude-3-5-sonnet-latest": { - provider_type: ProviderType.ANTHROPIC, - model: "claude-3-5-sonnet-latest", - token_usage: { - input_tokens: 1183, - output_tokens: 433, - input_cost: 0.003549, - output_cost: 0.006495, + mswEndpoint("/api/v1/workspaces/archive/:workspace_name"), + () => new HttpResponse(null, { status: 204 }) + ), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), + () => { + return HttpResponse.json({ prompt: "foo" }); + } + ), + http.get( + mswEndpoint("/api/v1/workspaces/:workspace_name/token-usage"), + () => { + return HttpResponse.json({ + tokens_by_model: { + "claude-3-5-sonnet-latest": { + provider_type: ProviderType.ANTHROPIC, + model: "claude-3-5-sonnet-latest", + token_usage: { + input_tokens: 1183, + output_tokens: 433, + input_cost: 0.003549, + output_cost: 0.006495, + }, }, }, - }, - token_usage: { - input_tokens: 1183, - output_tokens: 433, - input_cost: 0.003549, - output_cost: 0.006495, - }, - }); - }), + token_usage: { + input_tokens: 1183, + output_tokens: 433, + input_cost: 0.003549, + output_cost: 0.006495, + }, + }); + } + ), http.put( - "*/api/v1/workspaces/:name/custom-instructions", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/workspaces/:workspace_name/custom-instructions"), + () => new HttpResponse(null, { status: 204 }) ), - http.get("*/api/v1/workspaces/:workspace_name/muxes", () => + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), () => HttpResponse.json([ { provider_id: "openai", @@ -107,34 +118,34 @@ export const handlers = [ model: "davinci", matcher_type: "catch_all", }, - ]), + ]) ), http.put( - "*/api/v1/workspaces/:workspace_name/muxes", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/workspaces/:workspace_name/muxes"), + () => new HttpResponse(null, { status: 204 }) ), - http.get("*/api/v1/provider-endpoints/:provider_name/models", () => - HttpResponse.json(mockedProvidersModels), + http.get(mswEndpoint("/api/v1/provider-endpoints/:provider_id/models"), () => + HttpResponse.json(mockedProvidersModels) ), - http.get("*/api/v1/provider-endpoints/models", () => - HttpResponse.json(mockedProvidersModels), + http.get(mswEndpoint("/api/v1/provider-endpoints/models"), () => + HttpResponse.json(mockedProvidersModels) ), - http.get("*/api/v1/provider-endpoints/:provider_id", () => - HttpResponse.json(mockedProviders[0]), + http.get(mswEndpoint("/api/v1/provider-endpoints/:provider_id"), () => + HttpResponse.json(mockedProviders[0]) ), - http.get("*/api/v1/provider-endpoints", () => - HttpResponse.json(mockedProviders), + http.get(mswEndpoint("/api/v1/provider-endpoints"), () => + HttpResponse.json(mockedProviders) ), http.post( - "*/api/v1/provider-endpoints", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/provider-endpoints"), + () => new HttpResponse(null, { status: 204 }) ), http.put( - "*/api/v1/provider-endpoints", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/provider-endpoints"), + () => new HttpResponse(null, { status: 204 }) ), http.delete( - "*/api/v1/provider-endpoints", - () => new HttpResponse(null, { status: 204 }), + mswEndpoint("/api/v1/provider-endpoints"), + () => new HttpResponse(null, { status: 204 }) ), ]; diff --git a/src/mocks/msw/mockers/alert.mock.ts b/src/mocks/msw/mockers/alert.mock.ts new file mode 100644 index 00000000..7c96ca03 --- /dev/null +++ b/src/mocks/msw/mockers/alert.mock.ts @@ -0,0 +1,60 @@ +import { Alert } from "@/api/generated"; +import { faker } from "@faker-js/faker"; + +const ALERT_SECRET_FIELDS = { + trigger_string: "foo", + trigger_type: "codegate-secrets", +} satisfies Pick; + +const ALERT_MALICIOUS_FIELDS = { + trigger_string: { + name: "invokehttp", + type: "pypi", + status: "malicious", + description: "Python HTTP for Humans.", + }, + trigger_type: "codegate-context-retriever", +} satisfies Pick; + +const getBaseAlert = ({ + timestamp, +}: { + timestamp: string; +}): Omit => ({ + id: faker.string.uuid(), + prompt_id: faker.string.uuid(), + code_snippet: null, + trigger_category: "critical", + timestamp: timestamp, +}); + +export const mockAlert = ({ + type, +}: { + type: "secret" | "malicious"; +}): Alert => { + const timestamp = faker.date.recent().toISOString(); + + const base: Omit = getBaseAlert({ + timestamp, + }); + + switch (type) { + case "malicious": { + const result: Alert = { + ...base, + ...ALERT_MALICIOUS_FIELDS, + }; + + return result; + } + case "secret": { + const result: Alert = { + ...base, + ...ALERT_SECRET_FIELDS, + }; + + return result; + } + } +}; diff --git a/src/mocks/msw/mockers/conversation.mock.ts b/src/mocks/msw/mockers/conversation.mock.ts new file mode 100644 index 00000000..e0461e68 --- /dev/null +++ b/src/mocks/msw/mockers/conversation.mock.ts @@ -0,0 +1,54 @@ +import { Conversation, QuestionType } from "@/api/generated"; +import { faker } from "@faker-js/faker"; +import { TOKEN_USAGE_AGG } from "./token-usage.mock"; +import { mockAlert } from "./alert.mock"; + +export function mockConversation({ + type = QuestionType.CHAT, + withTokenUsage = true, + alertsConfig = {}, +}: { + type?: QuestionType; + withTokenUsage?: boolean; + alertsConfig?: { + numAlerts?: number; + type?: "secret" | "malicious" | "any"; + }; +} = {}) { + const timestamp = faker.date.recent().toISOString(); + + return { + question_answers: [ + { + question: { + message: faker.lorem.sentence(), + timestamp: timestamp, + message_id: faker.string.uuid(), + }, + answer: { + message: faker.lorem.sentence(), + timestamp: timestamp, + message_id: faker.string.uuid(), + }, + }, + ], + provider: "vllm", + alerts: Array.from({ + length: + typeof alertsConfig?.numAlerts === "number" + ? alertsConfig?.numAlerts + : faker.number.int({ min: 0, max: 5 }), + }).map(() => + mockAlert({ + type: + alertsConfig?.type == null || alertsConfig.type === "any" + ? faker.helpers.arrayElement(["secret", "malicious"]) + : alertsConfig.type, + }), + ), + token_usage_agg: withTokenUsage ? TOKEN_USAGE_AGG : null, + type, + chat_id: faker.string.uuid(), // NOTE: This isn't a UUID in the API + conversation_timestamp: timestamp, + } as const satisfies Conversation; +} diff --git a/src/features/alerts/mocks/token-usage.mock.ts b/src/mocks/msw/mockers/token-usage.mock.ts similarity index 100% rename from src/features/alerts/mocks/token-usage.mock.ts rename to src/mocks/msw/mockers/token-usage.mock.ts diff --git a/src/routes/__tests__/route-dashboard.test.tsx b/src/routes/__tests__/route-dashboard.test.tsx index 0bb5097c..6d422dd4 100644 --- a/src/routes/__tests__/route-dashboard.test.tsx +++ b/src/routes/__tests__/route-dashboard.test.tsx @@ -8,6 +8,7 @@ import { HttpResponse, http } from "msw"; import mockedAlerts from "@/mocks/msw/fixtures/GET_ALERTS.json"; import userEvent from "@testing-library/user-event"; import { RouteDashboard } from "../route-dashboard"; +import { mswEndpoint } from "@/test/msw-endpoint"; const fakeConversionation1 = { conversation: { @@ -79,7 +80,7 @@ const fakeConversionation2 = { function mockAlertsWithMaliciousPkg() { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json([fakeConversionation1, fakeConversionation2]); }), ); @@ -87,7 +88,7 @@ function mockAlertsWithMaliciousPkg() { function mockManyAlerts() { server.use( - http.get("*/workspaces/:name/alerts", () => { + http.get(mswEndpoint("/api/v1/workspaces/:workspace_name/alerts"), () => { return HttpResponse.json( [ ...mockedAlerts, diff --git a/src/test/msw-endpoint.ts b/src/test/msw-endpoint.ts new file mode 100644 index 00000000..4f6e94a4 --- /dev/null +++ b/src/test/msw-endpoint.ts @@ -0,0 +1,25 @@ +import type json from "../api/openapi.json"; + +/** + * OpenAPI spec uses curly braces to denote path parameters + * @example + * ``` + * /api/v1/provider-endpoints/{provider_id}/models + * ``` + * + * MSW expects a colon prefix for path parameters + * @example + * ``` + * /api/v1/provider-endpoints/:provider_id/models + * ``` + */ +type ReplacePathParams = + T extends `${infer Start}{${infer Param}}${infer End}` + ? `${Start}:${Param}${ReplacePathParams}` + : T; + +type Endpoint = ReplacePathParams; + +export function mswEndpoint(endpoint: Endpoint) { + return new URL(endpoint, import.meta.env.VITE_BASE_API_URL).toString(); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 32f4a144..2aa07e67 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,4 +1,5 @@ /// interface ImportBaseApiEnv { readonly BASE_API_URL: string; + readonly VITE_BASE_API_URL: string; }