Skip to content
Draft
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
100 changes: 94 additions & 6 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,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 @@ -126,6 +127,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated,
onSelectConversation,
labels,
scenarioResultId,
}: {
onNewAttack: () => void;
activeTarget: unknown;
Expand All @@ -136,7 +138,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 @@ -146,6 +150,8 @@ jest.mock("./components/Chat/ChatWindow", () => {
<span data-testid="attack-target-hash">{attackTarget?.identifier_hash ?? "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 @@ -333,12 +339,12 @@ 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 MockScenarioRunPage = () => <div data-testid="scenario-run-page" />;
MockScenarioRunPage.displayName = "MockScenarioRunPage";
return {
__esModule: true,
default: MockScenarioRunStarted,
default: MockScenarioRunPage,
};
});

Expand Down Expand Up @@ -417,14 +423,14 @@ 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", () => {
it("renders the scenario run dashboard and marks the sidebar current when deep-linked to /scenario-history/:id", () => {
renderApp("/scenario-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("switches to the scenarios view via the sidebar", () => {
Expand Down Expand Up @@ -812,6 +818,70 @@ describe("App", () => {
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main")
);
expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main");
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none");
});

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 () => {
Expand Down Expand Up @@ -843,6 +913,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
30 changes: 18 additions & 12 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import Initializers from './components/Initializers/Initializers'
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 @@ -31,6 +31,11 @@ import {
import { attacksApi, versionApi } from './services/api'
import { toApiError } from './services/errors'
import { useTour } from './hooks/useTour'
import {
attackConversationRoutePath,
attackRoutePath,
scenarioRunProvenance,
} from './utils/routeParams'

const AUTO_DISMISS_MS = 5_000

Expand Down Expand Up @@ -73,10 +78,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 @@ -122,6 +123,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 @@ -277,10 +282,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 @@ -318,16 +323,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, 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 All @@ -353,6 +358,7 @@ function App() {
attackTarget={readyAttack ? readyAttack.target : null}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
scenarioResultId={readyAttack ? scenarioResultId : null}
/>
)

Expand Down Expand Up @@ -423,7 +429,7 @@ function App() {
/>
}
/>
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunStarted />} />
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunPage />} />
<Route
path="/history"
element={
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/components/Chat/ChatWindow.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ export const useChatWindowStyles = makeStyles({
backgroundColor: tokens.colorNeutralBackground2,
overflow: 'hidden',
},
breadcrumbBar: {
display: 'flex',
alignItems: 'center',
flexShrink: 0,
minHeight: '36px',
paddingInline: tokens.spacingHorizontalL,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
backgroundColor: tokens.colorNeutralBackground3,
overflowX: 'auto',
},
breadcrumbLink: {
color: tokens.colorBrandForegroundLink,
textDecorationLine: 'none',
whiteSpace: 'nowrap',
':hover': {
textDecorationLine: 'underline',
},
':focus-visible': {
outline: `2px solid ${tokens.colorStrokeFocus2}`,
outlineOffset: '2px',
},
},
conversationDrawer: {
width: '280px',
minWidth: '280px',
Expand Down
58 changes: 57 additions & 1 deletion frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { MemoryRouter, Route, Routes } from "react-router";
import ChatWindow from "./ChatWindow";
import { makeTarget } from "@/test-utils/targetFixtures";
import { Message, TargetCapabilities, TargetInfo, TargetInstance } from "../../types";
Expand Down Expand Up @@ -58,7 +59,11 @@ const MARKDOWN_PREFERENCE_STORAGE_KEY = "pyrit.chatMarkdownMode";

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
}) => <FluentProvider theme={webLightTheme}>{children}</FluentProvider>;
}) => (
<FluentProvider theme={webLightTheme}>
<MemoryRouter>{children}</MemoryRouter>
</FluentProvider>
);

function mockMatchMedia(matchesNarrowScreen: boolean): void {
(window.matchMedia as jest.Mock).mockImplementation((query: string) => ({
Expand Down Expand Up @@ -318,6 +323,57 @@ describe("ChatWindow Integration", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});

it("shows a safe scenario-run breadcrumb only when provenance is present", () => {
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
const { rerender } = render(
<TestWrapper>
<ChatWindow {...defaultProps} scenarioResultId={scenarioResultId} />
</TestWrapper>
);

expect(screen.getByRole("navigation", { name: "Attack provenance" })).toBeInTheDocument();
expect(screen.getByRole("link", {
name: `Return to scenario run ${scenarioResultId}`,
})).toHaveAttribute("href", `/scenario-history/${scenarioResultId}`);

rerender(
<TestWrapper>
<ChatWindow {...defaultProps} scenarioResultId={null} />
</TestWrapper>
);
expect(screen.queryByRole("navigation", { name: "Attack provenance" })).not.toBeInTheDocument();
});

it("returns to the originating scenario run from the breadcrumb", async () => {
const user = userEvent.setup();
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
render(
<FluentProvider theme={webLightTheme}>
<MemoryRouter initialEntries={["/attacks/attack-1"]}>
<Routes>
<Route
path="/attacks/:attackResultId"
element={<ChatWindow {...defaultProps} scenarioResultId={scenarioResultId} />}
/>
<Route
path="/scenario-history/:scenarioResultId"
element={<h1>Originating scenario run</h1>}
/>
</Routes>
</MemoryRouter>
</FluentProvider>
);

await user.click(screen.getByRole("link", {
name: `Return to scenario run ${scenarioResultId}`,
}));

expect(screen.getByRole("heading", {
level: 1,
name: "Originating scenario run",
})).toBeInTheDocument();
});

it("defaults to raw mode when no Markdown preference is stored", () => {
render(
<TestWrapper>
Expand Down
Loading