diff --git a/frontend/e2e/routing.spec.ts b/frontend/e2e/routing.spec.ts new file mode 100644 index 0000000000..3c6633871f --- /dev/null +++ b/frontend/e2e/routing.spec.ts @@ -0,0 +1,242 @@ +import { test, expect, type Page } from "@playwright/test"; + +// --------------------------------------------------------------------------- +// Helpers – mock backend API responses so URL-driven navigation can be +// exercised without a real backend. These tests are the only e2e coverage +// that asserts on the browser address bar (deep links, refresh, back/forward). +// --------------------------------------------------------------------------- + +/** Attacks the mocked backend "knows about". Unknown ids return 404. */ +const KNOWN_ATTACKS: Record = { + "atk-1": { outcome: "success" }, + "atk-success": { outcome: "success" }, + "atk-failure": { outcome: "failure" }, +}; + +/** Build an attack summary for the single-attack (getAttack) endpoint. */ +function makeAttackSummary(attackResultId: string, outcome: "success" | "failure") { + return { + attack_result_id: attackResultId, + conversation_id: `conv-${attackResultId}`, + attack_type: "SingleTurnAttack", + target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" }, + converters: [], + outcome, + last_message_preview: null, + message_count: 1, + related_conversation_ids: [], + labels: { operator: "alice" }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; +} + +/** Build a single assistant message whose text identifies its attack. */ +function makeMessage(attackResultId: string) { + const text = `Loaded from ${attackResultId}`; + return { + turn_number: 1, + role: "assistant", + created_at: new Date().toISOString(), + pieces: [ + { + piece_id: `piece-${attackResultId}`, + original_value_data_type: "text", + converted_value_data_type: "text", + original_value: text, + converted_value: text, + scores: [], + response_error: "none", + }, + ], + }; +} + +/** A row in the attack history list. */ +function makeAttackRow(attackResultId: string, outcome: "success" | "failure") { + return { + attack_result_id: attackResultId, + conversation_id: `conv-${attackResultId}`, + attack_type: "SingleTurnAttack", + target: { target_type: "OpenAIChatTarget", model_name: "gpt-4o" }, + converters: [], + outcome, + last_message_preview: null, + message_count: 1, + related_conversation_ids: [], + labels: { operator: "alice" }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; +} + +const ATTACK_ROWS = [ + makeAttackRow("atk-success", "success"), + makeAttackRow("atk-failure", "failure"), +]; + +/** Register every API mock the routing tests rely on. */ +async function mockRoutingAPIs(page: Page) { + // No active target configured – the chat ribbon shows "No target selected". + await page.route(/\/api\/targets/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [] }), + }); + }); + + await page.route(/\/api\/attacks\/attack-options/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ attack_types: ["SingleTurnAttack"] }), + }); + }); + + await page.route(/\/api\/attacks\/converter-options/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ converter_types: [] }), + }); + }); + + await page.route(/\/api\/labels/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ source: "attacks", labels: { operator: ["alice"], operation: [] } }), + }); + }); + + // Conversation list for the loaded attack's side panel. + await page.route(/\/api\/attacks\/[^/]+\/conversations/, async (route) => { + const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: attackResultId, + main_conversation_id: `conv-${attackResultId}`, + conversations: [], + }), + }); + }); + + // Messages for the loaded conversation – text encodes the attack id so a + // test can prove the URL's attack actually hydrated the chat. + await page.route(/\/api\/attacks\/[^/]+\/messages/, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + conversation_id: `conv-${attackResultId}`, + messages: [makeMessage(attackResultId)], + }), + }); + }); + + // Single attack (getAttack). Unknown ids 404 to drive the not-found UX. + await page.route(/\/api\/attacks\/[^/]+$/, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const attackResultId = new URL(route.request().url()).pathname.split("/")[3] ?? ""; + const known = KNOWN_ATTACKS[attackResultId]; + if (!known) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ detail: "Attack not found" }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(makeAttackSummary(attackResultId, known.outcome)), + }); + }); + + // Attacks list with outcome filtering (mirrors the real query contract). + await page.route(/\/api\/attacks(?:\?|$)/, async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + const outcome = new URL(route.request().url()).searchParams.get("outcome"); + const items = outcome ? ATTACK_ROWS.filter((a) => a.outcome === outcome) : ATTACK_ROWS; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items, + pagination: { limit: 25, has_more: false, next_cursor: null, prev_cursor: null }, + }), + }); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test.describe("URL-driven routing", () => { + test.beforeEach(async ({ page }) => { + await mockRoutingAPIs(page); + }); + + test("history filters round-trip through the URL and survive a reload", async ({ page }) => { + await page.goto("/history"); + await expect(page.getByTestId("attacks-table")).toBeVisible({ timeout: 10_000 }); + + // Select the "Success" outcome filter. + await page.getByTestId("outcome-filter").click(); + await page.getByRole("option", { name: "Success" }).click(); + + // The filter is reflected in the query string and the list narrows. + await expect(page).toHaveURL(/[?&]outcome=success/); + await expect(page.getByTestId("attack-row-atk-success")).toBeVisible(); + await expect(page.getByTestId("attack-row-atk-failure")).not.toBeVisible(); + + // A full page reload restores the filter from the URL alone. + await page.reload(); + await expect(page).toHaveURL(/[?&]outcome=success/); + await expect(page.getByTestId("attack-row-atk-success")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("attack-row-atk-failure")).not.toBeVisible(); + }); + + test("deep-links into an attack and hydrates its conversation", async ({ page }) => { + await page.goto("/attacks/atk-1"); + + // The router keeps the deep link, and the attack named by the URL – not + // some default/empty conversation – drives the chat window. + await expect(page).toHaveURL(/\/attacks\/atk-1$/); + await expect(page.getByText("Loaded from atk-1")).toBeVisible({ timeout: 10_000 }); + }); + + test("shows the not-found screen for an unknown attack id", async ({ page }) => { + await page.goto("/attacks/bogus-id-12345"); + + await expect(page.getByTestId("attack-not-found")).toBeVisible({ timeout: 10_000 }); + }); + + test("browser back returns from an opened attack to history", async ({ page }) => { + await page.goto("/history"); + await expect(page.getByTestId("attacks-table")).toBeVisible({ timeout: 10_000 }); + + await page.getByTestId("attack-row-atk-success").click(); + await expect(page).toHaveURL(/\/attacks\/atk-success$/); + + await page.goBack(); + await expect(page).toHaveURL(/\/history$/); + await expect(page.getByTestId("attacks-table")).toBeVisible(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ee8de78a1..ba2005b8c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,8 @@ "axios": "1.17.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-error-boundary": "6.1.2" + "react-error-boundary": "6.1.2", + "react-router-dom": "7.16.0" }, "devDependencies": { "@eslint/js": "10.0.1", @@ -5506,6 +5507,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -9000,6 +9014,44 @@ "dev": true, "license": "MIT" }, + "node_modules/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "license": "MIT", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -9136,6 +9188,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cae1a19cd2..3552419697 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,7 +29,8 @@ "axios": "1.17.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-error-boundary": "6.1.2" + "react-error-boundary": "6.1.2", + "react-router-dom": "7.16.0" }, "devDependencies": { "@eslint/js": "10.0.1", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index d3896fa297..75be5c50b8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -4,6 +4,7 @@ */ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import App from "./App"; import { attacksApi } from "./services/api"; @@ -176,11 +177,16 @@ jest.mock("./components/Config/TargetConfig", () => { jest.mock("./components/History/AttackHistory", () => { const MockAttackHistory = ({ onOpenAttack, + filters, + onFiltersChange, }: { onOpenAttack: (attackResultId: string) => void; + filters: Record; + onFiltersChange: (filters: Record) => void; }) => { return (
+ {JSON.stringify(filters)} +
); }; @@ -239,19 +251,29 @@ jest.mock("./components/Home/Home", () => { }); describe("App", () => { + // App reads the active view from the URL, so every render needs a router. + // initialPath lets a test deep-link straight to a view (e.g. "/config"). + function renderApp(initialPath = "/") { + return render( + + + + ); + } + beforeEach(() => { jest.clearAllMocks(); mockGetActiveAccount.mockReturnValue(null); }); it("renders with FluentProvider and MainLayout", () => { - render(); + renderApp(); expect(screen.getByTestId("main-layout")).toBeInTheDocument(); expect(screen.getByTestId("home-view")).toBeInTheDocument(); }); it("starts in dark mode", () => { - render(); + renderApp(); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-dark-mode", "true" @@ -259,7 +281,7 @@ describe("App", () => { }); it("toggles theme when onToggleTheme is called", () => { - render(); + renderApp(); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-dark-mode", @@ -280,7 +302,37 @@ describe("App", () => { }); it("starts in home view", () => { - render(); + renderApp(); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "home" + ); + expect(screen.getByTestId("home-view")).toBeInTheDocument(); + }); + + it("renders the view named by the initial URL", () => { + renderApp("/config"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "config" + ); + expect(screen.getByTestId("target-config")).toBeInTheDocument(); + }); + + it("renders the history view when deep-linked to /history", () => { + renderApp("/history"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "history" + ); + expect(screen.getByTestId("attack-history")).toBeInTheDocument(); + }); + + it("redirects an unknown path back to home", () => { + renderApp("/does-not-exist"); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", @@ -290,7 +342,7 @@ describe("App", () => { }); it("switches to chat view", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-chat")); @@ -302,7 +354,7 @@ describe("App", () => { }); it("switches to config view", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-config")); @@ -314,7 +366,7 @@ describe("App", () => { }); it("switches back to chat from config", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-config")); expect(screen.getByTestId("target-config")).toBeInTheDocument(); @@ -324,7 +376,7 @@ describe("App", () => { }); it("sets conversationId from chat window", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-chat")); expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"); @@ -334,7 +386,7 @@ describe("App", () => { }); it("clears conversationId on new attack", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-chat")); fireEvent.click(screen.getByTestId("set-conversation")); @@ -345,7 +397,7 @@ describe("App", () => { }); it("sets active target from config page and passes to chat", () => { - render(); + renderApp(); // Switch to chat and confirm no target initially fireEvent.click(screen.getByTestId("nav-chat")); @@ -361,7 +413,7 @@ describe("App", () => { }); it("switches to history view", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-history")); @@ -374,7 +426,7 @@ describe("App", () => { it("opens attack from history and switches to chat", async () => { mockGetAttack.mockResolvedValue({ attack_result_id: "ar-attack-1", conversation_id: "attack-conv-1", labels: { operator: "roakey" } }); - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-history")); fireEvent.click(screen.getByTestId("open-attack")); @@ -393,7 +445,7 @@ describe("App", () => { conversation_id: "home-conv-1", labels: { operator: "roakey" }, }); - render(); + renderApp(); fireEvent.click(screen.getByTestId("home-open-attack")); @@ -406,7 +458,7 @@ describe("App", () => { }); it("navigates to config from the home view", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("home-go-config")); @@ -417,9 +469,9 @@ describe("App", () => { expect(screen.getByTestId("target-config")).toBeInTheDocument(); }); - it("handles failed attack open gracefully", async () => { + it("shows the not-found UX when an attack fails to load", async () => { mockGetAttack.mockRejectedValue(new Error("Not found")); - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-history")); fireEvent.click(screen.getByTestId("open-attack")); @@ -427,8 +479,9 @@ describe("App", () => { // Should switch to chat view even on error expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "chat"); await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-attack-1")); - // Conversation should be cleared on error - await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("none")); + // The chat window is replaced by an inline "attack not found" message + await waitFor(() => expect(screen.getByTestId("attack-not-found")).toBeInTheDocument()); + expect(screen.queryByTestId("chat-window")).not.toBeInTheDocument(); }); it("clears activeConversationId synchronously before fetching a new attack", async () => { @@ -445,7 +498,7 @@ describe("App", () => { () => new Promise((resolve) => { resolveGetAttack = resolve }) ); - render(); + renderApp(); // Simulate: user is already on attack A with a branched conv selected. fireEvent.click(screen.getByTestId("nav-chat")); @@ -462,12 +515,14 @@ describe("App", () => { fireEvent.click(screen.getByTestId("open-attack-2")); // ar-attack-2 // BEFORE getAttack resolves: ChatWindow must NOT see the stale conv id - // alongside the new attack id. This is the invariant the fix establishes. + // alongside the new attack id. While attack B loads, its data is not yet + // ready, so both the attack id and conversation id are withheld — which + // gates ChatWindow's /messages fetch and prevents the cross-attack 400. expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", "chat" ); - expect(screen.getByTestId("attack-result-id")).toHaveTextContent("ar-attack-2"); + expect(screen.getByTestId("attack-result-id")).toHaveTextContent("none"); expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("none"); expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"); @@ -489,7 +544,7 @@ describe("App", () => { default_labels: { operator: "default_user", custom: "value" }, }); - render(); + renderApp(); // The version API is called on mount and labels get merged await waitFor(() => { @@ -512,7 +567,7 @@ describe("App", () => { default_labels: { custom: "value" }, }); - render(); + renderApp(); // Home receives the same labels prop — assert there to avoid racing the // async initLabels effect against a view-change re-render. @@ -530,7 +585,7 @@ describe("App", () => { default_labels: { operator: "backend_user", custom: "value" }, }); - render(); + renderApp(); await waitFor(() => { const labels = screen.getByTestId("home-labels-json").textContent ?? ""; @@ -540,7 +595,7 @@ describe("App", () => { }); it("stores attack target when conversation is created with active target", () => { - render(); + renderApp(); // Set a target first fireEvent.click(screen.getByTestId("nav-config")); @@ -553,7 +608,7 @@ describe("App", () => { }); it("sets active conversation when onSelectConversation is called", () => { - render(); + renderApp(); fireEvent.click(screen.getByTestId("nav-chat")); @@ -565,4 +620,92 @@ describe("App", () => { fireEvent.click(screen.getByTestId("select-conversation")); // The component re-renders with the new conversation ID }); + + it("hydrates attack state when deep-linked to /attacks/:attackId", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + renderApp("/attacks/ar-1"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "chat"); + await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-1")); + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main"); + }); + + it("uses the conversation from a deep link when it belongs to the attack", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: ["conv-related"], + }); + renderApp("/attacks/ar-1/conversations/conv-related"); + + await waitFor(() => + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-related") + ); + }); + + it("falls back to the main conversation when the deep-linked conversation is unknown", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: ["conv-related"], + }); + renderApp("/attacks/ar-1/conversations/bogus"); + + // The unknown conversation segment is stripped and we fall back to main. + await waitFor(() => + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main") + ); + }); + + it("hydrates history filters from the URL query string", () => { + renderApp("/history?outcome=success&attackType=PromptSendingAttack"); + + const filters = JSON.parse( + screen.getByTestId("history-filters").textContent ?? "{}" + ); + expect(filters.outcome).toBe("success"); + expect(filters.attackTypes).toEqual(["PromptSendingAttack"]); + }); + + it("writes filter changes into the URL", () => { + renderApp("/history"); + + expect( + JSON.parse(screen.getByTestId("history-filters").textContent ?? "{}").outcome + ).toBe(""); + + fireEvent.click(screen.getByTestId("set-outcome-filter")); + + // The change flows out to the URL and back into the derived filters prop. + expect( + JSON.parse(screen.getByTestId("history-filters").textContent ?? "{}").outcome + ).toBe("success"); + }); + + it("restores history filters when returning via the nav button", () => { + renderApp("/history?outcome=success"); + + expect( + JSON.parse(screen.getByTestId("history-filters").textContent ?? "{}").outcome + ).toBe("success"); + + // Leave history for another view, then come back via the nav button. + fireEvent.click(screen.getByTestId("nav-config")); + expect(screen.getByTestId("target-config")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("nav-history")); + expect( + JSON.parse(screen.getByTestId("history-filters").textContent ?? "{}").outcome + ).toBe("success"); + }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73b05f82e2..7f43374815 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,58 @@ -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { Routes, Route, Navigate, useNavigate, useLocation, useSearchParams, matchPath } from 'react-router-dom' import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components' import { useMsal } from '@azure/msal-react' import MainLayout from './components/Layout/MainLayout' import ChatWindow from './components/Chat/ChatWindow' +import AttackNotFound from './components/Chat/AttackNotFound' import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' import AttackHistory from './components/History/AttackHistory' -import { DEFAULT_HISTORY_FILTERS } from './components/History/historyFilters' import type { HistoryFilters } from './components/History/historyFilters' import { ConnectionBanner } from './components/ConnectionBanner' import { ErrorBoundary } from './components/ErrorBoundary' import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnectionHealth' import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' +import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters' import type { ViewName } from './components/Sidebar/Navigation' import type { TargetInstance, TargetInfo } from './types' import { attacksApi, versionApi } from './services/api' const AUTO_DISMISS_MS = 5_000 +/** Maps each navigable view to its canonical URL path. */ +const VIEW_PATHS: Record = { + home: '/', + chat: '/chat', + history: '/history', + config: '/config', +} + +/** Resolves the active view from a URL path, defaulting to home for unknown paths. */ +function viewFromPath(pathname: string): ViewName { + const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find( + ([, path]) => path === pathname, + ) + return match ? match[0] : 'home' +} + +/** Status of the in-flight attack load for an /attacks/:id route. */ +type AttackLoadStatus = 'loading' | 'success' | 'not-found' + +/** Attack data named by the URL; `id` marks which attack the data belongs to. */ +interface LoadedAttack { + id: string + mainConversationId: string | null + labels: Record | null + target: TargetInfo | null + relatedConversationIds: string[] + status: AttackLoadStatus +} + +const attackPath = (attackId: string) => `/attacks/${attackId}` +const conversationPath = (attackId: string, conversationId: string) => + `/attacks/${attackId}/conversations/${conversationId}` + function ConnectionBannerContainer() { const { status, reconnectCount } = useConnectionHealth() // Track how many reconnects the user has already had the banner dismissed for. @@ -42,14 +77,46 @@ function ConnectionBannerContainer() { function App() { const { instance } = useMsal() + const navigate = useNavigate() + const location = useLocation() + + // The URL is the source of truth for which attack/conversation is open. + const conversationMatch = matchPath( + { path: '/attacks/:attackId/conversations/:conversationId', end: true }, + location.pathname, + ) + const attackMatch = matchPath({ path: '/attacks/:attackId', end: true }, location.pathname) + const routeAttackId = conversationMatch?.params.attackId ?? attackMatch?.params.attackId ?? null + const routeConversationId = conversationMatch?.params.conversationId ?? null + const currentView: ViewName = routeAttackId !== null ? 'chat' : viewFromPath(location.pathname) + const [isDarkMode, setIsDarkMode] = useState(true) - const [currentView, setCurrentView] = useState('home') const [activeTarget, setActiveTarget] = useState(null) const [globalLabels, setGlobalLabels] = useState>({ ...DEFAULT_GLOBAL_LABELS }) - /** True while loading a historical attack from the history view */ - const [isLoadingAttack, setIsLoadingAttack] = useState(false) - /** Persisted filter state for the history view */ - const [historyFilters, setHistoryFilters] = useState({ ...DEFAULT_HISTORY_FILTERS }) + + // History filters live in the URL query string so they are shareable and + // survive refresh. The breadcrumb ref remembers the last /history query so + // the History nav button can restore filters after visiting another view. + const [searchParams, setSearchParams] = useSearchParams() + const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]) + const lastHistorySearch = useRef('') + useEffect(() => { + if (location.pathname === VIEW_PATHS.history) { + lastHistorySearch.current = location.search + } + }, [location.pathname, location.search]) + + const handleFiltersChange = useCallback((filters: HistoryFilters) => { + setSearchParams(filtersToSearchParams(filters), { replace: true }) + }, [setSearchParams]) + + /** Attack named by the URL, hydrated by the loader effect below. */ + const [loadedAttack, setLoadedAttack] = useState(null) + // When set, the loader skips exactly one fetch for this id — used after + // first-message/branch creation seeds the data, avoiding a redundant getAttack. + const skipNextLoadForAttackId = useRef(null) + // The attack whose deep-linked conversation id we have already validated. + const validatedConversationForAttack = useRef(null) // Fetch default labels from backend, then override operator with active account if available useEffect(() => { @@ -100,87 +167,161 @@ function App() { return target }) }, []) - /** The AttackResult's primary key (set on first message). */ - const [attackResultId, setAttackResultId] = useState(null) - /** The attack's primary conversation_id (set on first message). */ - const [conversationId, setConversationId] = useState(null) - /** The currently active conversation (may be main or a related conversation). */ - const [activeConversationId, setActiveConversationId] = useState(null) - /** Labels that the currently loaded attack was created with (for operator locking). */ - const [attackLabels, setAttackLabels] = useState | null>(null) - /** Target info from the currently loaded historical attack (for cross-target guard). */ - const [attackTarget, setAttackTarget] = useState(null) - /** Number of related conversations for the currently loaded attack. */ - const [relatedConversationCount, setRelatedConversationCount] = useState(0) - - const clearAttackState = useCallback(() => { - setAttackResultId(null) - setConversationId(null) - setActiveConversationId(null) - setAttackLabels(null) - setAttackTarget(null) - setRelatedConversationCount(0) - }, []) + // Hydrate loadedAttack from the routed attack id. Depends on routeAttackId + // ONLY, so switching conversations within an attack never refetches. + useEffect(() => { + if (!routeAttackId) { + // Intentional cleanup of async-sourced state, not a derivable render value. + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoadedAttack(null) + validatedConversationForAttack.current = null + return + } + if (skipNextLoadForAttackId.current === routeAttackId) { + skipNextLoadForAttackId.current = null + return + } + let cancelled = false + setLoadedAttack({ + id: routeAttackId, + status: 'loading', + mainConversationId: null, + labels: null, + target: null, + relatedConversationIds: [], + }) + attacksApi + .getAttack(routeAttackId) + .then(attack => { + if (cancelled) return + setLoadedAttack({ + id: routeAttackId, + mainConversationId: attack.conversation_id, + labels: attack.labels ?? {}, + target: attack.target ?? null, + relatedConversationIds: attack.related_conversation_ids ?? [], + status: 'success', + }) + }) + .catch(() => { + if (cancelled) return + setLoadedAttack({ + id: routeAttackId, + status: 'not-found', + mainConversationId: null, + labels: null, + target: null, + relatedConversationIds: [], + }) + }) + // Drop a stale response once the route has moved on to another attack. + return () => { cancelled = true } + }, [routeAttackId]) - const handleNewAttack = () => { - clearAttackState() - } + // Only the attack named by the current URL may drive the chat. While a new + // attack is loading, loadedAttack still holds the previous one, so this keeps + // its data from being mixed with the new route's id (the stale-conv guard). + const attackForRoute = loadedAttack && loadedAttack.id === routeAttackId ? loadedAttack : null + const readyAttack = attackForRoute?.status === 'success' ? attackForRoute : null + const isAttackNotFound = attackForRoute?.status === 'not-found' + const isLoadingAttack = routeAttackId !== null && !readyAttack && !isAttackNotFound + const activeConversationId = readyAttack + ? routeConversationId ?? readyAttack.mainConversationId + : null - const handleConversationCreated = useCallback((arId: string, convId: string) => { - setAttackResultId(arId) - setConversationId(convId) - setActiveConversationId(convId) - // New attack was created by the current user — use their global labels - setAttackLabels(null) - // Record the target used for this attack so the cross-target guard - // fires if the user switches targets mid-conversation. - if (activeTarget) { - const { target_type, endpoint, model_name } = activeTarget - setAttackTarget({ target_type, endpoint, model_name }) + // Validate a deep-linked conversation id once per attack load. In-app + // conversation navigation is trusted (it targets conversations ChatWindow + // just created or listed), so only the initial URL is checked. + useEffect(() => { + if (!readyAttack) return + if (validatedConversationForAttack.current === readyAttack.id) return + validatedConversationForAttack.current = readyAttack.id + if (routeConversationId) { + const isKnown = + routeConversationId === readyAttack.mainConversationId || + readyAttack.relatedConversationIds.includes(routeConversationId) + if (!isKnown) { + navigate(attackPath(readyAttack.id), { replace: true }) + } + } + }, [readyAttack, routeConversationId, navigate]) + + const handleNavigate = useCallback((view: ViewName) => { + // Re-attach the last filter query so returning to history restores filters. + if (view === 'history') { + navigate(VIEW_PATHS.history + lastHistorySearch.current) + return } - }, [activeTarget]) + navigate(VIEW_PATHS[view]) + }, [navigate]) + + const handleNewAttack = useCallback(() => { + navigate(VIEW_PATHS.chat) + }, [navigate]) + + const handleConversationCreated = useCallback((arId: string, convId: string) => { + // Seed the freshly-created attack synchronously and tell the loader to skip + // its next fetch for this id, so the attack opens without a redundant load. + const target: TargetInfo | null = activeTarget + ? { + target_type: activeTarget.target_type, + endpoint: activeTarget.endpoint, + model_name: activeTarget.model_name, + } + : null + skipNextLoadForAttackId.current = arId + setLoadedAttack({ + id: arId, + mainConversationId: convId, + // New attack uses the current user's labels, so it is never operator-locked. + labels: null, + target, + relatedConversationIds: [], + status: 'success', + }) + // Replace when promoting an empty /chat to its attack url (first message); + // push when branching from an existing attack so Back returns to the source. + navigate(attackPath(arId), { replace: routeAttackId === null }) + }, [activeTarget, routeAttackId, navigate]) const handleSelectConversation = useCallback((convId: string) => { - setActiveConversationId(convId) - // Messages will be loaded by ChatWindow's useEffect - }, []) + if (!routeAttackId) return + navigate(conversationPath(routeAttackId, convId)) + }, [routeAttackId, navigate]) - const handleOpenAttack = useCallback(async (openAttackResultId: string) => { - // Synchronously clear per-attack state before flipping attackResultId so - // ChatWindow does not fetch /messages with a conv_id that belonged to the - // previously loaded attack while getAttack is in flight. The branched- - // conversation case (activeConversationId pointing to a related conv of - // the old attack) would otherwise produce a 400 from the backend. - // Skip clearing when re-opening the same attack to avoid a redundant reload. - if (openAttackResultId !== attackResultId) { - setConversationId(null) - setActiveConversationId(null) - setAttackLabels(null) - setAttackTarget(null) - setRelatedConversationCount(0) - } - setAttackResultId(openAttackResultId) - setIsLoadingAttack(true) - setCurrentView('chat') - // Fetch attack info to get conversation_id and stored labels (for operator locking) - try { - const attack = await attacksApi.getAttack(openAttackResultId) - setConversationId(attack.conversation_id) - setActiveConversationId(attack.conversation_id) - setAttackLabels(attack.labels ?? {}) - setAttackTarget(attack.target ?? null) - setRelatedConversationCount(attack.related_conversation_ids?.length ?? 0) - } catch { - clearAttackState() - } finally { - setIsLoadingAttack(false) - } - }, [attackResultId, clearAttackState]) + const handleOpenAttack = useCallback((openAttackResultId: string) => { + navigate(attackPath(openAttackResultId)) + }, [navigate]) const toggleTheme = () => { setIsDarkMode(!isDarkMode) } + const chatElement = isAttackNotFound ? ( + navigate(VIEW_PATHS.chat)} + onBackToHistory={() => navigate(VIEW_PATHS.history)} + /> + ) : ( + + ) + return ( @@ -188,50 +329,56 @@ function App() { - {currentView === 'home' && ( - + + } + /> + + - )} - {currentView === 'chat' && ( - - )} - {currentView === 'config' && ( - + } /> - )} - {currentView === 'history' && ( - + } /> - )} + } /> + diff --git a/frontend/src/auth/AuthProvider.test.tsx b/frontend/src/auth/AuthProvider.test.tsx index bc9cc7ccc4..e96e138087 100644 --- a/frontend/src/auth/AuthProvider.test.tsx +++ b/frontend/src/auth/AuthProvider.test.tsx @@ -313,4 +313,106 @@ describe("AuthProvider", () => { expect(screen.getByTestId("consumer")).toBeVisible(); }); }); + + // Test 20: the originally requested path is passed as MSAL state on login, + // so it can be restored after the redirect round-trip. + it("passes the requested path as state to loginRedirect", async () => { + window.history.replaceState(null, "", "/attacks/atk-7?tab=related"); + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupIds: "g1", + }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(mockLoginRedirect).toHaveBeenCalledWith( + expect.objectContaining({ state: "/attacks/atk-7?tab=related" }) + ); + }); + }); + + // Test 21: a same-origin path returned as redirect state is restored. + it("restores the requested deep link from redirect state", async () => { + window.history.replaceState(null, "", "/"); + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupIds: "g1", + }); + mockHandleRedirectPromise.mockResolvedValue({ + account: { username: "user@test.com" }, + state: "/attacks/atk-7", + }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(window.location.pathname).toBe("/attacks/atk-7"); + }); + }); + + // Test 22: an absolute/cross-origin state is rejected (open-redirect guard). + it("ignores an unsafe cross-origin redirect state", async () => { + window.history.replaceState(null, "", "/"); + const replaceSpy = jest.spyOn(window.history, "replaceState"); + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupIds: "g1", + }); + mockHandleRedirectPromise.mockResolvedValue({ + account: { username: "user@test.com" }, + state: "https://evil.example.com/phish", + }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(mockSetActiveAccount).toHaveBeenCalled(); + }); + expect(replaceSpy).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe("/"); + }); + + // Test 23: a backslash-prefixed state ("/\evil.com") is rejected, since the + // URL parser normalizes "\" to "/" and would otherwise yield "//evil.com". + it("ignores a backslash-prefixed redirect state", async () => { + window.history.replaceState(null, "", "/"); + const replaceSpy = jest.spyOn(window.history, "replaceState"); + mockFetchAuthConfig.mockResolvedValue({ + clientId: "test-client", + tenantId: "test-tenant", + allowedGroupIds: "g1", + }); + mockHandleRedirectPromise.mockResolvedValue({ + account: { username: "user@test.com" }, + state: "/\\evil.example.com/phish", + }); + + render( + +
Child
+
+ ); + + await waitFor(() => { + expect(mockSetActiveAccount).toHaveBeenCalled(); + }); + expect(replaceSpy).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe("/"); + }); }); diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 5a597410ee..a7eba1cf86 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -31,9 +31,16 @@ function LoginRedirect() { const config = useAuthConfig() useEffect(() => { - instance.loginRedirect(buildLoginRequest(config.clientId)).catch((error) => { - console.error('Login redirect failed:', error) - }) + // Capture the path the user originally requested so it can be restored + // after the login round-trip (see the redirect handling in initMsal). + instance + .loginRedirect({ + ...buildLoginRequest(config.clientId), + state: window.location.pathname + window.location.search, + }) + .catch((error) => { + console.error('Login redirect failed:', error) + }) }, [instance, config]) return
Redirecting to login...
@@ -43,6 +50,17 @@ interface AuthProviderProps { children: ReactNode } +/** + * True for root-relative paths ("/history") that are safe to restore after a + * login redirect. Rejects protocol-relative ("//evil.com", "/\evil.com") and + * absolute URLs, which would otherwise turn a tampered MSAL state value into an + * open redirect. Backslashes are treated as slashes because the URL parser + * normalizes "\" to "/". + */ +function isSafeInternalPath(path: string): boolean { + return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\') +} + export function AuthProvider({ children }: AuthProviderProps) { const [msalInstance, setMsalInstance] = useState(null) const [authDisabled, setAuthDisabled] = useState(false) @@ -86,6 +104,18 @@ export function AuthProvider({ children }: AuthProviderProps) { instance.setActiveAccount(redirectResult.account) } + // Restore the deep link the user originally requested, captured as + // MSAL state before the login redirect (see LoginRedirect). Only + // same-origin paths are honored, guarding against an open redirect. + const requestedPath = typeof redirectResult?.state === 'string' ? redirectResult.state : null + if ( + requestedPath && + isSafeInternalPath(requestedPath) && + requestedPath !== window.location.pathname + window.location.search + ) { + window.history.replaceState(null, '', requestedPath) + } + // Fall back to any cached account from a previous session if (!instance.getActiveAccount()) { const accounts = instance.getAllAccounts() diff --git a/frontend/src/components/Chat/AttackNotFound.styles.ts b/frontend/src/components/Chat/AttackNotFound.styles.ts new file mode 100644 index 0000000000..a6d58d2e6b --- /dev/null +++ b/frontend/src/components/Chat/AttackNotFound.styles.ts @@ -0,0 +1,26 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useAttackNotFoundStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + height: '100%', + padding: tokens.spacingHorizontalXXL, + textAlign: 'center', + }, + detail: { + maxWidth: '420px', + color: tokens.colorNeutralForeground2, + }, + code: { + fontFamily: tokens.fontFamilyMonospace, + color: tokens.colorNeutralForeground1, + }, + actions: { + display: 'flex', + gap: tokens.spacingHorizontalM, + }, +}) diff --git a/frontend/src/components/Chat/AttackNotFound.test.tsx b/frontend/src/components/Chat/AttackNotFound.test.tsx new file mode 100644 index 0000000000..927fd030c0 --- /dev/null +++ b/frontend/src/components/Chat/AttackNotFound.test.tsx @@ -0,0 +1,44 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +import { render, screen, fireEvent } from "@testing-library/react"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import AttackNotFound from "./AttackNotFound"; + +function renderNotFound(props: Partial> = {}) { + const onStartNew = jest.fn(); + const onBackToHistory = jest.fn(); + render( + + + + ); + return { onStartNew, onBackToHistory }; +} + +describe("AttackNotFound", () => { + it("shows the missing attack id", () => { + renderNotFound(); + expect(screen.getByTestId("attack-not-found")).toBeInTheDocument(); + expect(screen.getByText("ar-missing")).toBeInTheDocument(); + }); + + it("calls onStartNew when the start button is clicked", () => { + const { onStartNew } = renderNotFound(); + fireEvent.click(screen.getByRole("button", { name: "Start a new attack" })); + expect(onStartNew).toHaveBeenCalledTimes(1); + }); + + it("calls onBackToHistory when the back button is clicked", () => { + const { onBackToHistory } = renderNotFound(); + fireEvent.click(screen.getByRole("button", { name: "Back to history" })); + expect(onBackToHistory).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/Chat/AttackNotFound.tsx b/frontend/src/components/Chat/AttackNotFound.tsx new file mode 100644 index 0000000000..34475fe95c --- /dev/null +++ b/frontend/src/components/Chat/AttackNotFound.tsx @@ -0,0 +1,32 @@ +import { Button, Text } from '@fluentui/react-components' +import { useAttackNotFoundStyles } from './AttackNotFound.styles' + +interface AttackNotFoundProps { + attackId: string + onStartNew: () => void + onBackToHistory: () => void +} + +export default function AttackNotFound({ attackId, onStartNew, onBackToHistory }: AttackNotFoundProps) { + const styles = useAttackNotFoundStyles() + + return ( +
+ + Attack not found + + + No attack matches the id {attackId}. It may have been + deleted, or the link may be incorrect. + +
+ + +
+
+ ) +} diff --git a/frontend/src/components/History/historyFilters.test.ts b/frontend/src/components/History/historyFilters.test.ts new file mode 100644 index 0000000000..974129bc9f --- /dev/null +++ b/frontend/src/components/History/historyFilters.test.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +import { + DEFAULT_HISTORY_FILTERS, + filtersFromSearchParams, + filtersToSearchParams, +} from "./historyFilters"; +import type { HistoryFilters } from "./historyFilters"; + +describe("historyFilters URL encoding", () => { + it("decodes empty params to the default filters", () => { + expect(filtersFromSearchParams(new URLSearchParams())).toEqual(DEFAULT_HISTORY_FILTERS); + }); + + it("encodes default filters to an empty query string", () => { + expect(filtersToSearchParams(DEFAULT_HISTORY_FILTERS).toString()).toBe(""); + }); + + it("round-trips a fully populated filter set", () => { + const filters: HistoryFilters = { + attackTypes: ["PromptSendingAttack", "CrescendoAttack"], + outcome: "success", + converter: ["Base64Converter", "ROT13Converter"], + converterMatchMode: "all", + hasConverters: true, + operator: ["roakey"], + operation: ["op_trash_panda"], + otherLabels: ["env:prod", "team:red"], + labelSearchText: "prod", + }; + const decoded = filtersFromSearchParams(filtersToSearchParams(filters)); + expect(decoded).toEqual(filters); + }); + + it("omits the converter match mode when it is the default 'any'", () => { + const params = filtersToSearchParams({ + ...DEFAULT_HISTORY_FILTERS, + converter: ["Base64Converter"], + converterMatchMode: "any", + }); + expect(params.has("converterMatch")).toBe(false); + }); + + it("preserves hasConverters=false distinctly from undefined", () => { + const explicitFalse = filtersToSearchParams({ + ...DEFAULT_HISTORY_FILTERS, + hasConverters: false, + }); + expect(explicitFalse.get("hasConverters")).toBe("false"); + expect(filtersFromSearchParams(explicitFalse).hasConverters).toBe(false); + + const unset = filtersToSearchParams(DEFAULT_HISTORY_FILTERS); + expect(unset.has("hasConverters")).toBe(false); + expect(filtersFromSearchParams(unset).hasConverters).toBeUndefined(); + }); + + it("repeats multi-value keys for list filters", () => { + const params = filtersToSearchParams({ + ...DEFAULT_HISTORY_FILTERS, + attackTypes: ["A", "B"], + }); + expect(params.getAll("attackType")).toEqual(["A", "B"]); + }); +}); diff --git a/frontend/src/components/History/historyFilters.ts b/frontend/src/components/History/historyFilters.ts index f6116356b6..67ed384b47 100644 --- a/frontend/src/components/History/historyFilters.ts +++ b/frontend/src/components/History/historyFilters.ts @@ -23,3 +23,34 @@ export const DEFAULT_HISTORY_FILTERS: HistoryFilters = { otherLabels: [], labelSearchText: '', } + +/** Builds the history filter state from a URL query string. */ +export function filtersFromSearchParams(params: URLSearchParams): HistoryFilters { + const hasConverters = params.get('hasConverters') + return { + attackTypes: params.getAll('attackType'), + outcome: params.get('outcome') ?? '', + converter: params.getAll('converter'), + converterMatchMode: params.get('converterMatch') === 'all' ? 'all' : 'any', + hasConverters: hasConverters === null ? undefined : hasConverters === 'true', + operator: params.getAll('operator'), + operation: params.getAll('operation'), + otherLabels: params.getAll('label'), + labelSearchText: params.get('labelSearch') ?? '', + } +} + +/** Encodes history filter state into a URL query string, omitting inactive filters. */ +export function filtersToSearchParams(filters: HistoryFilters): URLSearchParams { + const params = new URLSearchParams() + for (const attackType of filters.attackTypes) params.append('attackType', attackType) + if (filters.outcome) params.set('outcome', filters.outcome) + for (const converter of filters.converter) params.append('converter', converter) + if (filters.converterMatchMode === 'all') params.set('converterMatch', 'all') + if (filters.hasConverters !== undefined) params.set('hasConverters', String(filters.hasConverters)) + for (const operator of filters.operator) params.append('operator', operator) + for (const operation of filters.operation) params.append('operation', operation) + for (const label of filters.otherLabels) params.append('label', label) + if (filters.labelSearchText) params.set('labelSearch', filters.labelSearchText) + return params +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9ff966daf5..5477deeaff 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' import App from './App' import { AuthProvider } from './auth/AuthProvider' import './styles/global.css' @@ -9,7 +10,9 @@ document.title = 'Co-PyRIT' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 50f1c97e45..fef4ebf3a1 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -1,4 +1,10 @@ import "@testing-library/jest-dom"; +import { TextEncoder, TextDecoder } from "util"; + +// jsdom omits TextEncoder/TextDecoder, which react-router v7 references at +// import time. Node's util provides spec-compatible implementations. +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder as typeof global.TextDecoder; // Set Vite-equivalent env vars for tests (the AST transformer rewrites // import.meta.env.X → process.env.X, so these must exist as process.env). diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py index c2c2f477cf..479ea153e8 100644 --- a/pyrit/backend/main.py +++ b/pyrit/backend/main.py @@ -14,6 +14,9 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response +from starlette.types import Scope import pyrit from pyrit.backend.middleware import RequestIdMiddleware, SecurityHeadersMiddleware, register_error_handlers @@ -126,6 +129,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(version.router, tags=["version"]) +class SPAStaticFiles(StaticFiles): + """Serve index.html for unmatched non-API paths so client-side routes survive a refresh.""" + + async def get_response(self, path: str, scope: Scope) -> Response: # pyrit-async-suffix-exempt + """Return the static file for ``path``, falling back to index.html for unmatched non-API paths.""" + try: + return await super().get_response(path, scope) + except StarletteHTTPException as exc: + # ``path`` arrives OS-normalized (backslashes on Windows), so compare + # against a forward-slash form to reliably detect the /api namespace. + normalized = path.replace(os.sep, "/") + if exc.status_code == 404 and not (normalized == "api" or normalized.startswith("api/")): + return await super().get_response("index.html", scope) + raise + + def setup_frontend() -> None: """Set up frontend static file serving.""" frontend_path = Path(__file__).parent / "frontend" @@ -136,7 +155,7 @@ def setup_frontend() -> None: elif frontend_path.exists(): # Production mode: serve bundled frontend print(f"✅ Serving frontend from {frontend_path}") - app.mount("/", StaticFiles(directory=str(frontend_path), html=True), name="frontend") + app.mount("/", SPAStaticFiles(directory=str(frontend_path), html=True), name="frontend") else: # Production mode but no frontend found - warn but don't exit # This allows API-only usage diff --git a/tests/unit/backend/test_main.py b/tests/unit/backend/test_main.py index b05ec5f6c8..2649c99e47 100644 --- a/tests/unit/backend/test_main.py +++ b/tests/unit/backend/test_main.py @@ -9,9 +9,14 @@ import logging import os +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -from pyrit.backend.main import app, lifespan, setup_frontend +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from pyrit.backend.main import SPAStaticFiles, app, lifespan, setup_frontend from pyrit.setup.configuration_loader import ConfigurationLoader @@ -131,3 +136,61 @@ def test_frontend_missing_warns_but_continues(self) -> None: # Verify warning was printed printed = " ".join(str(c) for c in mock_print.call_args_list) assert "warning" in printed.lower() + + +@pytest.fixture +def spa_client(tmp_path: Path) -> TestClient: + """Build a TestClient whose root is an SPAStaticFiles mount over a fake frontend build.""" + (tmp_path / "index.html").write_text("spa-index") + assets_dir = tmp_path / "assets" + assets_dir.mkdir() + (assets_dir / "app.js").write_text("console.log('real asset')") + + test_app = FastAPI() + + @test_app.get("/api/real") + def _real() -> dict[str, bool]: + return {"ok": True} + + test_app.mount("/", SPAStaticFiles(directory=str(tmp_path), html=True), name="frontend") + return TestClient(test_app) + + +class TestSPAStaticFiles: + """Tests for the SPA fallback that serves index.html on unmatched non-API paths.""" + + def test_root_serves_index(self, spa_client: TestClient) -> None: + """Test that the root path serves index.html.""" + resp = spa_client.get("/") + assert resp.status_code == 200 + assert "spa-index" in resp.text + + def test_serves_real_asset(self, spa_client: TestClient) -> None: + """Test that an existing static asset is served directly, not the fallback.""" + resp = spa_client.get("/assets/app.js") + assert resp.status_code == 200 + assert "real asset" in resp.text + + def test_unknown_spa_path_serves_index(self, spa_client: TestClient) -> None: + """Test that a deep client-side route falls back to index.html with a 200.""" + resp = spa_client.get("/attacks/ar-99") + assert resp.status_code == 200 + assert "spa-index" in resp.text + + def test_nested_unknown_spa_path_serves_index(self, spa_client: TestClient) -> None: + """Test that a multi-segment client-side route also falls back to index.html.""" + resp = spa_client.get("/attacks/ar-99/conversations/c-1") + assert resp.status_code == 200 + assert "spa-index" in resp.text + + def test_unknown_api_path_still_404(self, spa_client: TestClient) -> None: + """Test that an unknown /api path stays a real 404 instead of being masked by index.html.""" + resp = spa_client.get("/api/bogus") + assert resp.status_code == 404 + assert "spa-index" not in resp.text + + def test_api_prefixed_client_route_serves_index(self, spa_client: TestClient) -> None: + """Test that a client route merely starting with "api" (e.g. /apikeys) still falls back to index.html.""" + resp = spa_client.get("/apikeys") + assert resp.status_code == 200 + assert "spa-index" in resp.text