diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4d9187855a..6f399e7f2b 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -127,6 +127,7 @@ jest.mock("./components/Layout/MainLayout", () => { }); jest.mock("./components/Chat/ChatWindow", () => { + const { useLocation } = jest.requireActual("react-router") as typeof import("react-router"); const MockChatWindow = ({ onNewAttack, activeTarget, @@ -140,6 +141,7 @@ jest.mock("./components/Chat/ChatWindow", () => { onConversationCreated, onSelectConversation, labels, + scenarioResultId, }: { onNewAttack: () => void; activeTarget: unknown; @@ -153,7 +155,9 @@ jest.mock("./components/Chat/ChatWindow", () => { onConversationCreated: (attackResultId: string, conversationId: string) => void; onSelectConversation: (convId: string) => void; labels: Record; + scenarioResultId?: string | null; }) => { + const location = useLocation(); return (
{attackResultId ?? "none"} @@ -168,6 +172,8 @@ jest.mock("./components/Chat/ChatWindow", () => { {targetResolutionStatus ?? "none"} {labels.operator ?? ""} {JSON.stringify(labels)} + {scenarioResultId ?? "none"} + {`${location.pathname}${location.search}`} @@ -365,12 +371,16 @@ jest.mock("./components/Scenarios/ScenarioDetail", () => { }; }); -jest.mock("./components/Scenarios/ScenarioRunStarted", () => { - const MockScenarioRunStarted = () =>
; - MockScenarioRunStarted.displayName = "MockScenarioRunStarted"; +jest.mock("./components/Scenarios/ScenarioRunPage", () => { + const { useLocation } = jest.requireActual("react-router"); + const MockScenarioRunPage = () => { + const location = useLocation(); + return
; + }; + MockScenarioRunPage.displayName = "MockScenarioRunPage"; return { __esModule: true, - default: MockScenarioRunStarted, + default: MockScenarioRunPage, }; }); @@ -464,14 +474,36 @@ describe("App", () => { expect(screen.getByTestId("scenario-detail")).toBeInTheDocument(); }); - it("renders the scenario run-started shell and marks the sidebar current when deep-linked to /scenario-history/:id", () => { - renderApp("/scenario-history/sr-123"); + it("renders the scanner run dashboard and marks the sidebar current when deep-linked to /scanner-history/:id", () => { + renderApp("/scanner-history/sr-123"); expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", "scenarios" ); - expect(screen.getByTestId("scenario-run-started")).toBeInTheDocument(); + expect(screen.getByTestId("scenario-run-page")).toBeInTheDocument(); + }); + + it("renders the scanner run dashboard for a direct attack detail link", () => { + renderApp("/scanner-history/sr-123/attack-456"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "scenarios" + ); + expect(screen.getByTestId("scenario-run-page")).toHaveAttribute( + "data-location", + "/scanner-history/sr-123/attack-456" + ); + }); + + it("redirects legacy scenario-history links to scanner-history", async () => { + renderApp("/scenario-history/sr-123"); + + expect(await screen.findByTestId("scenario-run-page")).toHaveAttribute( + "data-location", + "/scanner-history/sr-123" + ); }); it("switches to the scenarios view via the sidebar", () => { @@ -937,6 +969,7 @@ describe("App", () => { ); expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main"); expect(screen.getByTestId("objective")).toHaveTextContent("Extract the hidden system prompt"); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); }); it("hides the normalized empty objective of an unnamed manual attack on reload", async () => { @@ -956,6 +989,69 @@ describe("App", () => { expect(screen.getByTestId("objective")).toHaveTextContent(""); }); + it("hydrates validated scenario provenance on a direct attack reload", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + + renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`); + + await waitFor(() => + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId) + ); + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1?scenarioResultId=${scenarioResultId}` + ); + }); + + it.each([ + "/attacks/ar-1?scenarioResultId=run-1", + "/attacks/ar-1?scenarioResultId=https%3A%2F%2Fevil.example", + "/attacks/ar-1?scenarioResultId=123e4567-e89b-12d3-a456-426614174000&scenarioResultId=123e4567-e89b-12d3-a456-426614174000", + ])("ignores unsafe or ambiguous scenario provenance on %s", async (path: string) => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + + renderApp(path); + + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); + }); + + it("preserves validated provenance within an attack and clears it for a new attack", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: ["conv-456"], + }); + renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`); + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + + fireEvent.click(screen.getByTestId("select-conversation")); + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1/conversations/conv-456?scenarioResultId=${scenarioResultId}` + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId); + + fireEvent.click(screen.getByTestId("new-attack")); + expect(screen.getByTestId("route-location")).toHaveTextContent("/chat"); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none"); + }); + it("uses the conversation from a deep link when it belongs to the attack", async () => { mockGetAttack.mockResolvedValue({ attack_result_id: "ar-1", @@ -985,6 +1081,24 @@ describe("App", () => { ); }); + it("retains validated provenance while canonicalizing an unknown conversation route", async () => { + const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000"; + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + labels: {}, + related_conversation_ids: [], + }); + renderApp(`/attacks/ar-1/conversations/bogus?scenarioResultId=${scenarioResultId}`); + + await waitFor(() => + expect(screen.getByTestId("route-location")).toHaveTextContent( + `/attacks/ar-1?scenarioResultId=${scenarioResultId}` + ) + ); + expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId); + }); + it("hydrates history filters from the URL query string", () => { renderApp("/history?outcome=success&attackType=PromptSendingAttack"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4d32f9672e..831e960d8d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react' -import { Routes, Route, Navigate, useNavigate, useLocation, useSearchParams, matchPath } from 'react-router' +import { Routes, Route, Navigate, useNavigate, useLocation, useParams, useSearchParams, matchPath } from 'react-router' import { useMsal } from '@azure/msal-react' import { Joyride } from 'react-joyride' import { useTheme } from './hooks/useTheme' @@ -12,7 +12,7 @@ import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' import ScenarioDetail from './components/Scenarios/ScenarioDetail' -import ScenarioRunStarted from './components/Scenarios/ScenarioRunStarted' +import ScenarioRunPage from './components/Scenarios/ScenarioRunPage' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' import { ConnectionBanner } from './components/ConnectionBanner' @@ -33,6 +33,13 @@ import { import { attacksApi, authApi, versionApi } from './services/api' import { toApiError } from './services/errors' import { useTour } from './hooks/useTour' +import { + attackConversationRoutePath, + attackRoutePath, + routerPathParamValue, + scenarioRunProvenance, + scenarioRunRoutePath, +} from './utils/routeParams' const AUTO_DISMISS_MS = 5_000 @@ -49,11 +56,16 @@ const VIEW_PATHS: Record = { /** * Resolves the active view from a URL path, defaulting to home for unknown * paths. Scanner routes are prefix-matched (`/scanner/...` and - * `/scenario-history/...`) since they carry a path parameter rather than a + * `/scanner-history/...`) since they carry a path parameter rather than a * single canonical `VIEW_PATHS` entry. */ function viewFromPath(pathname: string): ViewName { - if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) { + if ( + pathname === VIEW_PATHS.scenarios + || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) + || pathname.startsWith('/scanner-history/') + || pathname.startsWith('/scenario-history/') + ) { return 'scenarios' } const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find( @@ -62,6 +74,11 @@ function viewFromPath(pathname: string): ViewName { return match ? match[0] : 'home' } +function LegacyScenarioRunRedirect() { + const { scenarioResultId } = useParams<{ scenarioResultId: string }>() + return +} + /** Status of the in-flight attack load for an /attacks/:id route. */ type AttackLoadStatus = 'loading' | 'success' | 'not-found' | 'error' @@ -78,10 +95,6 @@ interface LoadedAttack { 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. @@ -161,6 +174,10 @@ function App() { // the History nav button can restore filters after visiting another view. const [searchParams, setSearchParams] = useSearchParams() const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]) + const scenarioResultId = useMemo( + () => scenarioRunProvenance(searchParams), + [searchParams], + ) const lastHistorySearch = useRef('') useEffect(() => { if (location.pathname === VIEW_PATHS.history) { @@ -340,10 +357,10 @@ function App() { routeConversationId === readyAttack.mainConversationId || readyAttack.relatedConversationIds.includes(routeConversationId) if (!isKnown) { - navigate(attackPath(readyAttack.id), { replace: true }) + navigate(attackRoutePath(readyAttack.id, scenarioResultId), { replace: true }) } } - }, [readyAttack, routeConversationId, navigate]) + }, [readyAttack, routeConversationId, navigate, scenarioResultId]) const handleNavigate = useCallback((view: ViewName) => { // Re-attach the last filter query so returning to history restores filters. @@ -392,16 +409,16 @@ function App() { }) // 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 }) + navigate(attackRoutePath(arId), { replace: routeAttackId === null }) }, [activeTarget, handleSetActiveTarget, routeAttackId, navigate]) const handleSelectConversation = useCallback((convId: string) => { if (!routeAttackId) return - navigate(conversationPath(routeAttackId, convId)) - }, [routeAttackId, navigate]) + navigate(attackConversationRoutePath(routeAttackId, convId, scenarioResultId)) + }, [routeAttackId, navigate, scenarioResultId]) const handleOpenAttack = useCallback((openAttackResultId: string) => { - navigate(attackPath(openAttackResultId)) + navigate(attackRoutePath(openAttackResultId)) }, [navigate]) const chatElement = isAttackNotFound || isAttackError ? ( @@ -430,6 +447,7 @@ function App() { isLoadingAttack={isLoadingAttack} relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0} objective={readyAttack ? readyAttack.objective : ''} + scenarioResultId={readyAttack ? scenarioResultId : null} /> ) @@ -500,7 +518,9 @@ function App() { /> } /> - } /> + } /> + } /> + } /> } /> { + if (childName !== 'technique_seeds') { + return undefined + } + return + } + + return ( +
+
+ Objective + + {objective ?? 'Objective text unavailable for this legacy attempt.'} + +
+ +
+ + + + + +
+ +
+ Attack technique +
+ {techniqueName} + {techniqueDescription} + {techniqueTags.length > 0 && ( +
+ {techniqueTags.map((tag) => ( + {tag} + ))} +
+ )} + {attempt.technique_details && ( + + )} +
+
+ +
+ Objective Scorer + {objectiveScorer + ? + : Objective scorer information is unavailable for this attempt.} +
+ +
+ Score rationale +
+ + {formatScoreRationale(attempt.score?.score_rationale)} + +
+
+ + + View conversation + + + {errorMessage && ( + + + {attempt.error_type ? `${attempt.error_type}: ` : ''} + {errorMessage} + + + )} +
+ ) +} + +interface TechniqueSeedProps { + readonly identity: ScenarioComponentIdentity +} + +function TechniqueSeed({ identity }: TechniqueSeedProps) { + const styles = useAttackAttemptDetailsStyles() + const value = typeof identity.parameters.value === 'string' ? identity.parameters.value : '' + const dataType = typeof identity.parameters.data_type === 'string' + ? identity.parameters.data_type + : 'text' + + return ( +
+ {identity.component_name} + +
+ ) +} + +interface TechniqueSeedValueProps { + readonly componentName: string + readonly dataType: string + readonly value: string +} + +function TechniqueSeedValue({ componentName, dataType, value }: TechniqueSeedValueProps) { + const styles = useAttackAttemptDetailsStyles() + if (!value) { + return No seed content is available. + } + if (!isPathDataType(dataType)) { + return + } + + const kind = dataTypeToAttachmentKind(dataType) + const mediaUrl = buildMediaUrl(value) + if (kind === 'image') { + return {`${componentName} + } + if (kind === 'audio') { + return