Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 121 additions & 7 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -140,6 +141,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated,
onSelectConversation,
labels,
scenarioResultId,
}: {
onNewAttack: () => void;
activeTarget: unknown;
Expand All @@ -153,7 +155,9 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated: (attackResultId: string, conversationId: string) => void;
onSelectConversation: (convId: string) => void;
labels: Record<string, string>;
scenarioResultId?: string | null;
}) => {
const location = useLocation();
return (
<div data-testid="chat-window">
<span data-testid="attack-result-id">{attackResultId ?? "none"}</span>
Expand All @@ -168,6 +172,8 @@ jest.mock("./components/Chat/ChatWindow", () => {
<span data-testid="target-resolution-status">{targetResolutionStatus ?? "none"}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
<span data-testid="scenario-result-id">{scenarioResultId ?? "none"}</span>
<span data-testid="route-location">{`${location.pathname}${location.search}`}</span>
<button onClick={onNewAttack} data-testid="new-attack">
New Attack
</button>
Expand Down Expand Up @@ -365,12 +371,16 @@ jest.mock("./components/Scenarios/ScenarioDetail", () => {
};
});

jest.mock("./components/Scenarios/ScenarioRunStarted", () => {
const MockScenarioRunStarted = () => <div data-testid="scenario-run-started" />;
MockScenarioRunStarted.displayName = "MockScenarioRunStarted";
jest.mock("./components/Scenarios/ScenarioRunPage", () => {
const { useLocation } = jest.requireActual<typeof import("react-router")>("react-router");
const MockScenarioRunPage = () => {
const location = useLocation();
return <div data-testid="scenario-run-page" data-location={location.pathname} />;
};
MockScenarioRunPage.displayName = "MockScenarioRunPage";
return {
__esModule: true,
default: MockScenarioRunStarted,
default: MockScenarioRunPage,
};
});

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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",
Expand Down Expand Up @@ -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");

Expand Down
50 changes: 35 additions & 15 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand All @@ -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

Expand All @@ -49,11 +56,16 @@ const VIEW_PATHS: Record<ViewName, string> = {
/**
* 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(
Expand All @@ -62,6 +74,11 @@ function viewFromPath(pathname: string): ViewName {
return match ? match[0] : 'home'
}

function LegacyScenarioRunRedirect() {
const { scenarioResultId } = useParams<{ scenarioResultId: string }>()
return <Navigate replace to={scenarioRunRoutePath(routerPathParamValue(scenarioResultId))} />
}

/** Status of the in-flight attack load for an /attacks/:id route. */
type AttackLoadStatus = 'loading' | 'success' | 'not-found' | 'error'

Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -430,6 +447,7 @@ function App() {
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
objective={readyAttack ? readyAttack.objective : ''}
scenarioResultId={readyAttack ? scenarioResultId : null}
/>
)

Expand Down Expand Up @@ -500,7 +518,9 @@ function App() {
/>
}
/>
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunStarted />} />
<Route path="/scanner-history/:scenarioResultId/:attackResultId" element={<ScenarioRunPage />} />
<Route path="/scanner-history/:scenarioResultId" element={<ScenarioRunPage />} />
<Route path="/scenario-history/:scenarioResultId" element={<LegacyScenarioRunRedirect />} />
<Route path="/config" element={<Configuration />} />
<Route
path="/history"
Expand Down
Loading