From 6a5f44fa79be59bb09d56a14739fab79aadf7907 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 9 Oct 2026 15:38:12 -0400 Subject: [PATCH 1/3] Add operations and operation-scoped findings to CoPyRIT Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/gui/0_gui.md | 69 ++- frontend/e2e/labels-operation-picker.spec.ts | 112 ++-- frontend/e2e/operations.spec.ts | 176 +++++++ frontend/src/App.labels.test.tsx | 59 ++- frontend/src/App.test.tsx | 55 ++ frontend/src/App.tsx | 40 +- .../components/Chat/AttackNotFound.test.tsx | 9 + .../src/components/Chat/AttackNotFound.tsx | 4 +- .../src/components/Chat/ChatWindow.styles.ts | 12 + .../src/components/Chat/ChatWindow.test.tsx | 63 +++ frontend/src/components/Chat/ChatWindow.tsx | 140 +++-- .../Chat/FindingEvidenceDialog.styles.ts | 14 + .../Chat/FindingEvidenceDialog.test.tsx | 406 ++++++++++++++ .../components/Chat/FindingEvidenceDialog.tsx | 325 ++++++++++++ .../Labels/LabelsBar.operations.test.tsx | 199 +++++++ .../src/components/Labels/LabelsBar.styles.ts | 13 +- .../src/components/Labels/LabelsBar.test.tsx | 381 +++++++------- frontend/src/components/Labels/LabelsBar.tsx | 267 +++------- .../src/components/Labels/OperationPicker.tsx | 119 +++++ .../src/components/Labels/labelDefaults.ts | 1 - .../Operations/FindingDialog.styles.ts | 11 + .../Operations/FindingDialog.test.tsx | 145 +++++ .../components/Operations/FindingDialog.tsx | 162 ++++++ .../Operations/FindingEvidenceList.styles.ts | 11 + .../Operations/FindingEvidenceList.test.tsx | 86 +++ .../Operations/FindingEvidenceList.tsx | 133 +++++ .../Operations/OperationCreateDialog.tsx | 113 ++++ .../Operations/OperationDetailPage.test.tsx | 313 +++++++++++ .../Operations/OperationDetailPage.tsx | 243 +++++++++ .../Operations/Operations.styles.ts | 61 +++ .../Operations/OperationsPage.test.tsx | 109 ++++ .../components/Operations/OperationsPage.tsx | 63 +++ .../components/Sidebar/Navigation.test.tsx | 14 + .../src/components/Sidebar/Navigation.tsx | 13 + frontend/src/services/api.test.ts | 48 ++ frontend/src/services/api.ts | 65 +++ frontend/src/services/errors.ts | 16 + frontend/src/types/index.ts | 73 +++ frontend/src/utils/findingSeverity.test.ts | 20 + frontend/src/utils/findingSeverity.ts | 16 + frontend/src/utils/routeParams.test.ts | 13 + frontend/src/utils/routeParams.ts | 13 +- pyrit/backend/main.py | 2 + pyrit/backend/models/operations.py | 81 +++ pyrit/backend/routes/operations.py | 208 ++++++++ pyrit/backend/services/operation_service.py | 252 +++++++++ ...8d3e5f7a901_add_operations_and_findings.py | 83 +++ pyrit/memory/memory_interface.py | 277 ++++++++++ pyrit/memory/memory_models.py | 127 +++++ pyrit/models/__init__.py | 8 + pyrit/models/finding.py | 93 ++++ pyrit/models/operation.py | 51 ++ tests/unit/backend/test_operations.py | 496 ++++++++++++++++++ tests/unit/memory/test_analytics_migration.py | 4 +- tests/unit/memory/test_findings.py | 315 +++++++++++ .../test_operations_and_findings_migration.py | 121 +++++ tests/unit/models/test_finding.py | 85 +++ tests/unit/models/test_operation.py | 30 ++ 58 files changed, 5917 insertions(+), 521 deletions(-) create mode 100644 frontend/e2e/operations.spec.ts create mode 100644 frontend/src/components/Chat/FindingEvidenceDialog.styles.ts create mode 100644 frontend/src/components/Chat/FindingEvidenceDialog.test.tsx create mode 100644 frontend/src/components/Chat/FindingEvidenceDialog.tsx create mode 100644 frontend/src/components/Labels/LabelsBar.operations.test.tsx create mode 100644 frontend/src/components/Labels/OperationPicker.tsx create mode 100644 frontend/src/components/Operations/FindingDialog.styles.ts create mode 100644 frontend/src/components/Operations/FindingDialog.test.tsx create mode 100644 frontend/src/components/Operations/FindingDialog.tsx create mode 100644 frontend/src/components/Operations/FindingEvidenceList.styles.ts create mode 100644 frontend/src/components/Operations/FindingEvidenceList.test.tsx create mode 100644 frontend/src/components/Operations/FindingEvidenceList.tsx create mode 100644 frontend/src/components/Operations/OperationCreateDialog.tsx create mode 100644 frontend/src/components/Operations/OperationDetailPage.test.tsx create mode 100644 frontend/src/components/Operations/OperationDetailPage.tsx create mode 100644 frontend/src/components/Operations/Operations.styles.ts create mode 100644 frontend/src/components/Operations/OperationsPage.test.tsx create mode 100644 frontend/src/components/Operations/OperationsPage.tsx create mode 100644 frontend/src/utils/findingSeverity.test.ts create mode 100644 frontend/src/utils/findingSeverity.ts create mode 100644 pyrit/backend/models/operations.py create mode 100644 pyrit/backend/routes/operations.py create mode 100644 pyrit/backend/services/operation_service.py create mode 100644 pyrit/memory/alembic/versions/c8d3e5f7a901_add_operations_and_findings.py create mode 100644 pyrit/models/finding.py create mode 100644 pyrit/models/operation.py create mode 100644 tests/unit/backend/test_operations.py create mode 100644 tests/unit/memory/test_findings.py create mode 100644 tests/unit/memory/test_operations_and_findings_migration.py create mode 100644 tests/unit/models/test_finding.py create mode 100644 tests/unit/models/test_operation.py diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index d77bd99e16..f25d4440bc 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -34,7 +34,7 @@ To deploy an isolated instance for an external team, see [Deploy a New Instance] ## Views -CoPyRIT has three main views, accessible from the left sidebar: **Chat**, **Attack History**, and **Target Configuration**. The **Theme** menu is available at the bottom of the sidebar. +Use the left sidebar to switch between views, including **Chat**, **Attack History**, **Operations**, and **Target Configuration**. The **Theme** menu is available at the bottom of the sidebar. ### Themes @@ -369,11 +369,11 @@ Export stays available for read-only historical conversations, and is disabled w #### Labels -The labels bar above the page content is available across the GUI, including scanner setup, Home, Chat, and History. It shows the active labels for future attacks and scans, not the attribution of a historical run you are viewing. Click the labels icon to open **Default Labels** and add, edit, or remove custom labels. The required `operator` and `operation` controls remain in the bar, outside this popover, and cannot be removed. A signed-in operator is read-only. +The labels bar above the page content is available across the GUI, including scanner setup, Home, Chat, and History. It shows the active labels for future attacks and scans, not the attribution of a historical run you are viewing. Click the labels icon to open **Default Labels** and add, edit, or remove custom labels. **Operator:** is an always-visible text input; **Operation:** is an always-visible searchable dropdown outside this popover. Operator is required; an operation is optional and removable. A signed-in operator is read-only. In Chat, the active target, Markdown toggle, export menu, conversations panel toggle, and **New Attack** button share the right side of this bar. They wrap below the labels on narrow screens. -Clicking the `operation` label opens a picker listing the operations already recorded in memory, so you can choose one without typing it from memory. Typing a name that doesn't exist yet offers to create it. Very long lists show the first 200 and say how many are left, so type to narrow them. On narrow screens, use the labels icon to view or edit labels that do not fit inline. +Open the **Operation** dropdown to choose a saved operation. **New operation…** stays first, including while searching, and opens the creation dialog. Typing alone never selects an unsaved name. Very long lists show up to 200 saved choices and say how many match, so type to narrow them. The compact metadata controls wrap on narrow screens; custom labels that do not fit remain available through the labels icon. Your choices persist in this browser across navigation and refreshes. Backend configuration supplies defaults for labels you have not chosen, and the signed-in account alias takes precedence over the default or remembered operator during initialization. Scanner launches receive the active labels from this bar. @@ -459,6 +459,69 @@ In active runs and saved scenario results, **Atomic attack groups** defaults to Until you expand or collapse the section, its default follows the current group count as progress loads. Once you choose, the section keeps your choice during progress updates for the same run, even if the count crosses 20. Opening a different run resets to that run's count-based default. +### Operations and Findings + +An operation groups related red-teaming work. Inside it you record findings: +human assessments, whether or not an attack produced them. + +Open **Operations** in the sidebar and choose **New operation**. Names are unique, +ignoring case and surrounding spaces; if the name is taken, the dialog offers the +existing operation. The **Operation** dropdown in the labels bar applies the +selected operation to new attacks and scanner runs, and **New operation…** at the +top of that list creates one without leaving the page. + +Operations can't be renamed or deleted from the GUI. + +#### Findings + +Open an operation and choose **New finding**. + +| Field | Required | Values | +| --- | --- | --- | +| Title | Yes | Free text | +| Severity | Yes | Critical, Important, Moderate, Low, Informational, or Other (your own text). Defaults to Moderate. | +| Harm-type | No | A PyRIT harm category, Other (your own text), or Not set | +| Description | No | Free text | + +Findings are sorted by severity in the order above, newest first within each +level, 20 per page. **Edit** changes any field; the operation and creation time +stay fixed. **Delete** asks for confirmation and can't be undone. + +**View execution history** opens History filtered to the operation's exact name, +and the filter stays when you switch between the Attacks and Scanner tabs. Runs +labeled with a different capitalization or spacing of the name don't match. + +#### Conversation evidence + +To attach a saved conversation to a finding, open it in Chat and choose **Link to +finding**, the link icon next to Export. The picker lists findings from the +operation the attack was labeled with. Search by title, select one, and choose +**Attach**. If the attack's operation label doesn't exactly match a saved +operation, the button is unavailable. The current toolbar selection doesn't +change this. + +**New finding** in the picker opens the same form and attaches the conversation +once the finding is saved. If the finding saves but the attachment fails, the +finding is kept and the viewer offers **Retry attachment**, which won't create a +second finding. Retry works only from the original conversation. + +Evidence points to the live conversation, not a copy, so later messages show up +too. Attaching the same conversation again reports **Already attached**. + +On the operation page, **Evidence (n)** under a finding lists its conversations. +**Open conversation** reopens one in Chat. If a conversation no longer exists, +its entry stays, shows its ID and attachment time, and reads **Evidence +unavailable**. **Remove link** removes only the link, never the conversation. +Deleting a finding removes its links the same way. + +#### Upgrading an existing database + +One migration adds the operation, finding, and evidence tables and leaves +existing data alone. The backend applies it automatically when it starts, so +restart a running backend after upgrading. If you start memory with +`skip_schema_migration=True`, run the migration yourself. Downgrading refuses to +drop the tables while any operation exists. + ### Target Configuration The Configuration view manages the targets available for attacks. diff --git a/frontend/e2e/labels-operation-picker.spec.ts b/frontend/e2e/labels-operation-picker.spec.ts index 14825723c5..8df800f525 100644 --- a/frontend/e2e/labels-operation-picker.spec.ts +++ b/frontend/e2e/labels-operation-picker.spec.ts @@ -26,6 +26,7 @@ async function setupMocks( versionDelayMs?: number; defaultLabels?: Record; operatorLabels?: string[]; + savedOperations?: string[]; } = {}, ): Promise { let versionRequests = 0; @@ -63,6 +64,15 @@ async function setupMocks( }, })); } + if (path === "/operations") { + return route.fulfill(json({ + items: (options.savedOperations ?? operationLabels).map((name, index) => ({ + id: `saved-operation-${index}`, + name, + created_at: "2026-01-01T00:00:00Z", + })), + })); + } if (path === "/attacks") { return route.fulfill(json({ items: [], total: 0, limit: 5, offset: 0 })); } @@ -81,7 +91,7 @@ function json(body: unknown) { /** Opens the picker from the labels bar and returns the rendered listbox. */ async function openOperationPicker(page: Page) { await page.goto("/"); - const chip = page.getByTestId("label-operation"); + const chip = page.getByTestId("edit-label-operation"); await expect(chip).toBeVisible(); await chip.click(); @@ -100,14 +110,14 @@ test.describe("operation picker placement", () => { await expect(bar.getByText("New run labels", { exact: true })).toHaveCount(0); await expect(bar.getByText("Used for new attacks and scans.", { exact: false })).toHaveCount(0); - await bar.getByRole("button", { name: /^Edit operation, currently / }).click(); + await bar.getByRole("combobox", { name: "Operation" }).click(); await page.getByRole("option", { name: "op_beta", exact: true }).click(); - await expect(bar.getByRole("button", { name: "Edit operation, currently op_beta" })).toBeVisible(); + await expect(bar.getByRole("combobox", { name: "Operation" })).toBeVisible(); - await bar.getByRole("button", { name: /^Edit operator, currently / }).click(); - await page.getByRole("textbox", { name: "Value for operator label" }).fill("alice"); - await page.getByRole("textbox", { name: "Value for operator label" }).press("Enter"); - await expect(bar.getByRole("button", { name: "Edit operator, currently alice" })).toBeVisible(); + await bar.getByRole("textbox", { name: "Operator" }).click(); + await page.getByRole("textbox", { name: "Operator" }).fill("alice"); + await page.getByRole("textbox", { name: "Operator" }).press("Enter"); + await expect(bar.getByRole("textbox", { name: "Operator" })).toBeVisible(); await bar.getByTestId("labels-icon-btn").click(); const popover = page.getByRole("group").filter({ @@ -156,7 +166,7 @@ test.describe("operation picker placement", () => { await page.getByRole("region", { name: "Default Labels" }).evaluate( (bar: HTMLElement) => { bar.style.marginTop = "240px"; }, ); - await page.getByTestId("label-operation").click(); + await page.getByTestId("edit-label-operation").click(); const listbox = page.getByRole("listbox"); await expect(listbox).toBeVisible(); @@ -246,7 +256,9 @@ test.describe("operation picker placement", () => { JSON.stringify({ operator: "roakey", operation: "op_chosen_elsewhere" }), ); }); - await setupMocks(page, ["op_alpha", "op_beta"]); + await setupMocks(page, ["op_alpha", "op_beta"], { + savedOperations: ["op_alpha", "op_beta", "op_chosen_elsewhere"], + }); await openOperationPicker(page); await expect( @@ -280,11 +292,9 @@ test.describe("operation picker placement", () => { expect(Date.now() - started).toBeLessThan(3000); }); - test("keeps the operation in use reachable past the end of a long list", async ({ + test("keeps a legacy operation removable without offering it as a saved choice", async ({ page, }) => { - // The value in use goes to the front of the list. Cap the wrong end and it - // is the first thing to disappear — whether or not the request returned it. await page.setViewportSize({ width: 1280, height: 800 }); await page.addInitScript(() => { window.localStorage.setItem( @@ -299,11 +309,13 @@ test.describe("operation picker placement", () => { name: "op_chosen_elsewhere", exact: true, }); - await expect(inUse).toBeVisible(); - await inUse.click(); - await expect(page.getByTestId("label-operation")).toContainText( + await expect(inUse).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue( "op_chosen_elsewhere", ); + await page.getByRole("button", { name: "Remove operation label" }).click(); + await expect(page.getByRole("combobox", { name: "Operation" })).toBeVisible(); }); test("keeps an operation the saved list already holds past the cap", async ({ @@ -335,11 +347,11 @@ test.describe("operation picker persistence", () => { await openOperationPicker(page); await page.getByRole("option", { name: "op_beta", exact: true }).click(); - await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta"); await page.reload(); - await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta"); }); test("keeps an operation picked while the app was still starting up", async ({ @@ -362,13 +374,13 @@ test.describe("operation picker persistence", () => { await page .getByRole("option", { name: "op_picked_early", exact: true }) .click(); - await expect(page.getByTestId("label-operation")).toContainText( + await expect(page.getByTestId("edit-label-operation")).toHaveValue( "op_picked_early", ); // Let the slow response land; it must not undo the choice. await page.waitForTimeout(5000); - await expect(page.getByTestId("label-operation")).toContainText( + await expect(page.getByTestId("edit-label-operation")).toHaveValue( "op_picked_early", ); }); @@ -379,7 +391,7 @@ test.describe("operation picker persistence", () => { // Nothing is stored, and the backend supplies its own `operation` default // that lands after the bar is already usable. The only thing standing // between the pick and that late response is that the value on screen is - // no longer the built-in placeholder. + // no longer the untouched default. await page.setViewportSize({ width: 1280, height: 800 }); await setupMocks(page, ["op_alpha", "op_picked_early"], { versionDelayMs: 4000, @@ -390,12 +402,12 @@ test.describe("operation picker persistence", () => { await page .getByRole("option", { name: "op_picked_early", exact: true }) .click(); - await expect(page.getByTestId("label-operation")).toContainText( + await expect(page.getByTestId("edit-label-operation")).toHaveValue( "op_picked_early", ); await page.waitForTimeout(5000); - await expect(page.getByTestId("label-operation")).toContainText( + await expect(page.getByTestId("edit-label-operation")).toHaveValue( "op_picked_early", ); // What is on screen is also what a refresh would restore. @@ -416,7 +428,7 @@ test.describe("operation picker persistence", () => { await openOperationPicker(page); await page.getByRole("option", { name: "op_beta", exact: true }).click(); - await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta"); // A later visit, once the deployment configures an operator. await page.unrouteAll({ behavior: "ignoreErrors" }); @@ -425,10 +437,10 @@ test.describe("operation picker persistence", () => { }); await page.reload(); - await expect(page.getByTestId("label-operator")).toContainText( + await expect(page.getByTestId("edit-label-operator")).toHaveValue( "configured_user", ); - await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta"); }); test("lets the backend change a label it supplied, after you pick", async ({ @@ -443,7 +455,7 @@ test.describe("operation picker persistence", () => { await openOperationPicker(page); await page.getByRole("option", { name: "op_beta", exact: true }).click(); - await expect(page.getByTestId("label-operator")).toContainText( + await expect(page.getByTestId("edit-label-operator")).toHaveValue( "configured_day1", ); @@ -453,10 +465,10 @@ test.describe("operation picker persistence", () => { }); await page.reload(); - await expect(page.getByTestId("label-operator")).toContainText( + await expect(page.getByTestId("edit-label-operator")).toHaveValue( "configured_day2", ); - await expect(page.getByTestId("label-operation")).toContainText("op_beta"); + await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta"); }); }); @@ -471,7 +483,7 @@ test.describe("switching between labels", () => { await setupMocks(page, ["op_alpha", "op_beta"]); await openOperationPicker(page); - await page.getByTestId("label-operator").click(); + await page.getByTestId("edit-label-operator").click(); const operatorEditor = page.getByTestId("edit-label-operator"); await expect(operatorEditor).toBeVisible(); @@ -488,16 +500,16 @@ test.describe("switching between labels", () => { await setupMocks(page, ["op_alpha", "op_beta"]); await page.goto("/"); - await page.getByTestId("label-operator").click(); + await page.getByTestId("edit-label-operator").click(); await page.getByTestId("edit-label-operator").fill("alice"); - await page.getByTestId("label-operation").click(); + await page.getByTestId("edit-label-operation").click(); await expect(page.getByRole("listbox")).toBeVisible(); await page.waitForTimeout(500); await expect(page.getByRole("listbox")).toBeVisible(); // The operator edit still went in; only its clean-up was skipped. await page.keyboard.press("Escape"); - await expect(page.getByTestId("label-operator")).toContainText("alice"); + await expect(page.getByTestId("edit-label-operator")).toHaveValue("alice"); }); }); @@ -511,32 +523,36 @@ test.describe("finishing an edit another way", () => { await setupMocks(page, ["op_alpha"], { operatorLabels: ["roakey", "alice"] }); await page.goto("/"); - await page.getByTestId("label-operator").click(); + await page.getByTestId("edit-label-operator").click(); await page.getByTestId("edit-label-operator").fill("al"); await page.getByText("alice", { exact: true }).click(); await page.waitForTimeout(500); - await expect(page.getByTestId("label-operator")).toContainText("alice"); + await expect(page.getByTestId("edit-label-operator")).toHaveValue("alice"); }); - test("starts an edit when the chip is clicked beside the edit control", async ({ + test("keeps compact metadata inputs visible and creation first on mobile", async ({ page, }) => { - // The pill's padding sits outside the control that opens the editor, and - // only a real layout says where that padding actually is. - await page.setViewportSize({ width: 1280, height: 800 }); + await page.setViewportSize({ width: 360, height: 800 }); await setupMocks(page, ["op_alpha"]); await page.goto("/"); - const chip = page.getByTestId("label-operator"); - await expect(chip).toBeVisible(); - const badge = chip.locator("xpath=.."); - const box = await badge.boundingBox(); - if (!box) throw new Error("chip has no layout"); - - // Two pixels in from the pill's left edge is padding, not the control. - await page.mouse.click(box.x + 2, box.y + box.height / 2); - - await expect(page.getByTestId("edit-label-operator")).toBeVisible(); + const operator = page.getByRole("textbox", { name: "Operator" }); + const operation = page.getByRole("combobox", { name: "Operation" }); + await expect(operator).toBeVisible(); + await expect(operation).toBeVisible(); + expect(await operator.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(100); + expect(await operation.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(140); + await operation.click(); + await expect(page.getByRole("option").first()).toHaveText("New operation…"); + await operation.fill("missing"); + await expect(page.getByRole("option").first()).toHaveText("New operation…"); + await page.getByRole("option", { name: "New operation…" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("textbox", { name: "Name" })).toBeFocused(); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(operation).toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360); }); }); diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts new file mode 100644 index 0000000000..ac03c56803 --- /dev/null +++ b/frontend/e2e/operations.spec.ts @@ -0,0 +1,176 @@ +import { test, expect } from '@playwright/test' + +import type { Finding, Operation } from '../src/types' + +test('creates a unique operation and records findings within it @seeded', async ({ page, request }, testInfo) => { + test.setTimeout(90_000) + const name = `Red team / α% ${Date.now()}` + const title = 'Human assessment without an attack' + await page.goto('/findings') + await expect(page).toHaveURL(/\/operations$/) + await expect(page.getByRole('heading', { name: 'Operations', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Operations', exact: true })).toHaveAttribute('aria-current', 'page') + + await page.getByRole('region', { name: 'Operations', exact: true }).getByRole('button', { name: 'New operation' }).click() + let dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Name' }).fill(` ${name} `) + const createdResponse = page.waitForResponse(response => + response.url().endsWith('/api/operations') && response.request().method() === 'POST') + await dialog.getByRole('button', { name: 'Create operation' }).click() + const created = await createdResponse + expect(created.status()).toBe(201) + const operation: Operation = await created.json() + expect(operation.name).toBe(name) + await expect(page).toHaveURL(new RegExp(`/operations/${operation.id}$`)) + await expect(page.getByRole('heading', { level: 1, name })).toBeVisible() + await expect(page.getByRole('button', { name: 'Operations', exact: true })).toHaveAttribute('aria-current', 'page') + + await page.getByRole('link', { name: 'Operations' }).click() + await page.getByRole('region', { name: 'Operations', exact: true }).getByRole('button', { name: 'New operation' }).click() + dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Name' }).fill(` ${name.toUpperCase()} `) + await dialog.getByRole('button', { name: 'Create operation' }).click() + await expect(dialog.getByText(/already exists/i)).toBeVisible() + await dialog.getByRole('link', { name: `Open ${name}` }).click() + await expect(page).toHaveURL(new RegExp(`/operations/${operation.id}$`)) + + await page.getByRole('button', { name: 'New finding' }).click() + dialog = page.getByRole('dialog') + await expect(dialog.getByRole('combobox', { name: 'Operation' })).toHaveCount(0) + await dialog.getByRole('textbox', { name: 'Title' }).fill(title) + await dialog.getByRole('combobox', { name: 'Severity' }).selectOption('informational') + const savedResponse = page.waitForResponse(response => + response.url().endsWith(`/api/operations/${operation.id}/findings`) && response.request().method() === 'POST') + await dialog.getByRole('button', { name: 'Save finding' }).click() + const saved = await savedResponse + expect(saved.status()).toBe(201) + const finding: Finding = await saved.json() + expect(finding.operation_id).toBe(operation.id) + await expect(page.getByRole('heading', { level: 2, name: title })).toBeVisible() + await expect(page.getByRole('button', { name: 'New finding' })).toBeFocused() + await page.reload() + await expect(page.getByRole('heading', { level: 2, name: title })).toBeVisible() + + const versionResponse = await request.get('/api/version') + expect(versionResponse.ok()).toBeTruthy() + const version: { compatibility_id: string } = await versionResponse.json() + const headers = { 'PyRIT-Compatibility-ID': version.compatibility_id } + for (let index = 0; index < 21; index++) { + const response = await request.post(`/api/operations/${operation.id}/findings`, { + headers, data: { title: `Assessment ${index}`, description: '', severity: index === 20 ? 'critical' : 'low' }, + }) + + expect(response.status()).toBe(201) + } + await page.reload() + const region = page.getByRole('region', { name: 'Operation', exact: true }) + await expect(region.getByRole('listitem')).toHaveCount(20) + await expect(region.getByRole('heading', { level: 2 }).first()).toHaveText('Assessment 20') + await page.getByRole('button', { name: 'Next', exact: true }).click() + await expect(region.getByRole('listitem')).toHaveCount(2) + await expect(page.getByRole('heading', { level: 2, name: title })).toBeVisible() + + await page.goto('/operations') + await expect(page.getByRole('link', { name })).toBeVisible() + await page.goto('/operations/00000000-0000-4000-8000-000000000000') + await expect(page.getByRole('heading', { name: 'Operation not found' })).toBeVisible() + + await page.goto(`/operations/${operation.id}`) + await page.screenshot({ path: testInfo.outputPath('operation-desktop.png'), animations: 'disabled' }) + await page.setViewportSize({ width: 390, height: 844 }) + await expect(page.getByRole('heading', { level: 1, name })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy() + await page.getByRole('button', { name: 'New finding' }).click() + await expect(dialog.getByRole('textbox', { name: 'Title' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBeTruthy() + await page.screenshot({ path: testInfo.outputPath('operation-mobile-dialog.png'), animations: 'disabled' }) + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await expect(page.getByRole('button', { name: 'New finding' })).toBeFocused() +}) + +test('selects saved operations inline and revisits exact-name attack history @seeded', async ({ page, request }) => { + test.setTimeout(90_000) + const name = `Engagement / α% & ${Date.now()}` + await page.goto('/') + const bar = page.getByRole('region', { name: 'Default Labels' }) + await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() + await page.getByRole('option', { name: 'New operation…', exact: true }).click() + let dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(name) + const creation = page.waitForResponse(response => + response.url().endsWith('/api/operations') && response.request().method() === 'POST') + await dialog.getByRole('button', { name: 'Create operation' }).click() + const operation: Operation = await (await creation).json() + await expect(dialog).toHaveCount(0) + await expect(bar.getByRole('combobox', { name: 'Operation' })).toHaveValue(name) + await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toBeFocused() + await expect(page).toHaveURL(/\/$/) + await page.reload() + await expect(bar.getByRole('combobox', { name: 'Operation' })).toHaveValue(name) + + await bar.getByRole('button', { name: 'Remove operation label' }).click() + await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() + await page.getByTestId('edit-label-operation').fill('not saved anywhere') + await expect(page.getByRole('option', { name: 'not saved anywhere', exact: true })).toHaveCount(0) + await page.keyboard.press('Escape') + await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toBeVisible() + await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() + await page.getByRole('option', { name: 'New operation…', exact: true }).click() + dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill(` ${name.toUpperCase()} `) + await dialog.getByRole('button', { name: 'Create operation' }).click() + await dialog.getByRole('button', { name: 'Use existing', exact: true }).click() + await expect(bar.getByRole('combobox', { name: 'Operation' })).toHaveValue(name) + await expect(page).toHaveURL(/\/$/) + + const version = await (await request.get('/api/version')).json() + const headers = { 'PyRIT-Compatibility-ID': version.compatibility_id } + const targetResponse = await request.post('/api/targets', { headers, data: { type: 'TextTarget', params: {} } }) + expect(targetResponse.ok()).toBeTruthy() + const target = await targetResponse.json() + for (let index = 0; index < 7; index++) { + const response = await request.post('/api/attacks', { + headers, data: { target_registry_name: target.target_registry_name, operation: name }, + }) + expect(response.ok()).toBeTruthy() + const attack = await response.json() + const updated = await request.patch(`/api/attacks/${attack.attack_result_id}`, { + headers, data: { objective: `Offline saved history ${index}` }, + }) + expect(updated.ok()).toBeTruthy() + } + const other = await request.post('/api/attacks', { + headers, data: { target_registry_name: target.target_registry_name, operation: name.toUpperCase() }, + }) + expect(other.ok()).toBeTruthy() + const finding = await request.post(`/api/operations/${operation.id}/findings`, { + headers, data: { title: 'Independent human finding', description: '', severity: 'low' }, + }) + expect(finding.status()).toBe(201) + await page.goto(`/operations/${operation.id}`) + await expect(page.getByRole('heading', { name: 'Independent human finding', exact: true })).toBeVisible() + await expect(page.getByRole('region', { name: 'Recent attacks', exact: true })).toHaveCount(0) + await expect(page.getByRole('region', { name: 'Recent scanner runs', exact: true })).toHaveCount(0) + const historyLink = page.getByRole('link', { name: 'View execution history', exact: true }) + const href = await historyLink.getAttribute('href') + expect(new URL(href!, 'http://localhost').searchParams.get('operation')).toBe(name) + await historyLink.click() + await expect(page).toHaveURL(/\/history\/attacks\?/) + await expect(page.getByRole('row', { name: 'Open ManualAttack attack', exact: true })).toHaveCount(7) + await expect(page.getByRole('combobox', { name: 'All operations' })).toHaveValue(name) + await page.getByRole('tab', { name: 'Scanner', exact: true }).click() + await expect(page).toHaveURL(/\/history\/scanner\?/) + expect(new URL(page.url()).searchParams.get('operation')).toBe(name) + const returnedAttacks = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/attacks' && response.request().method() === 'GET') + await page.getByRole('tab', { name: 'Attacks', exact: true }).click() + await expect(page).toHaveURL(/\/history\/attacks\?/) + expect((await returnedAttacks).ok()).toBeTruthy() + await expect(page.getByRole('progressbar', { name: 'Loading attacks...' })).toHaveCount(0, { timeout: 30_000 }) + await expect(page.getByRole('row', { name: 'Open ManualAttack attack', exact: true })).toHaveCount(7) + await page.goto(`/operations/${operation.id}`) + await page.reload() + await expect(historyLink).toBeVisible() + await expect(page.getByRole('heading', { name: 'Independent human finding', exact: true })).toBeVisible() +}) diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index c2ad8fa90e..650587904e 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import { MemoryRouter } from 'react-router' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' -import { attacksApi, labelsApi, runtimeApi, scenariosApi, targetsApi, versionApi } from '@/services/api' +import { attacksApi, labelsApi, operationsApi, runtimeApi, scenariosApi, targetsApi, versionApi } from '@/services/api' import { makeTarget } from '@/test-utils/targetFixtures' import type { RegisteredScenario } from '@/types' import { exportConversation } from '@/utils/conversationExport' @@ -41,6 +41,7 @@ jest.mock('@/services/api', () => ({ authApi: { getAccess: jest.fn().mockResolvedValue({ isAdmin: false }) }, versionApi: { getVersion: jest.fn() }, labelsApi: { getLabels: jest.fn() }, + operationsApi: { list: jest.fn(), create: jest.fn() }, attacksApi: { listAttacks: jest.fn(), getAttack: jest.fn(), @@ -105,12 +106,14 @@ function currentLabels(): HTMLElement { } async function chooseOperation(user: ReturnType, operation: string): Promise { - await user.click(within(currentLabels()).getByRole('button', { name: /^Edit operation, currently / })) - await user.click(screen.getByRole('combobox', { name: 'Operation' })) + jest.mocked(operationsApi.list).mockResolvedValue({ + items: [{ id: 'operation-id', name: operation, created_at: '2026-10-07T16:00:00Z' }], + }) + await user.click(within(currentLabels()).getByRole('combobox', { name: 'Operation' })) await user.paste(operation) await user.keyboard('{ArrowDown}') - await user.click(await screen.findByRole('option', { name: `Create "${operation}"` })) - expect(within(currentLabels()).getByRole('button', { name: `Edit operation, currently ${operation}` })) + await user.click(await screen.findByRole('option', { name: operation, exact: true })) + expect(within(currentLabels()).getByRole('combobox', { name: 'Operation' })) .toBeInTheDocument() } @@ -131,6 +134,7 @@ describe('Shared new run labels', () => { jest.mocked(versionApi.getVersion).mockReset() jest.mocked(versionApi.getVersion).mockResolvedValue({ version: '1.0.0', default_labels: DEFAULT_LABELS }) jest.mocked(labelsApi.getLabels).mockResolvedValue({ source: 'attacks', labels: {} }) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [] }) jest.mocked(targetsApi.listTargets).mockResolvedValue({ items: [TARGET], pagination: { limit: 200, has_more: false }, }) @@ -175,8 +179,8 @@ describe('Shared new run labels', () => { it('edits operator, operation, and custom labels during scenario setup and sends them on launch', async () => { const user = userEvent.setup() renderApp() - await user.click(await screen.findByRole('button', { name: 'Edit operator, currently config_user' })) - const operator = screen.getByRole('textbox', { name: 'Value for operator label' }) + await user.click(await screen.findByRole('textbox', { name: 'Operator' })) + const operator = screen.getByRole('textbox', { name: 'Operator' }) await user.clear(operator) await user.paste('test_user') await user.keyboard('{Enter}') @@ -200,7 +204,7 @@ describe('Shared new run labels', () => { it('keeps one editor through navigation and restores choices without pinning backend defaults', async () => { const user = userEvent.setup() const app = renderApp() - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByRole('combobox', { name: 'Operation' }) await chooseOperation(user, 'remembered_op') const bar = currentLabels() @@ -208,7 +212,7 @@ describe('Shared new run labels', () => { await user.click(screen.getByRole('button', { name: destination, exact: true })) expect(screen.getAllByTestId('labels-bar')).toHaveLength(1) expect(currentLabels()).toBe(bar) - expect(within(bar).getByRole('button', { name: /currently remembered_op$/ })).toBeInTheDocument() + expect(within(bar).getByRole('combobox', { name: 'Operation' })).toHaveValue('remembered_op') } await user.click(await screen.findByRole('link', { name: 'test.scenario' })) await screen.findByRole('combobox', { name: 'Objective Target' }) @@ -222,7 +226,7 @@ describe('Shared new run labels', () => { }) renderApp() await screen.findByRole('button', { name: 'Edit team label, currently new_team' }) - expect(screen.getByRole('button', { name: /currently remembered_op$/ })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('remembered_op') }) it('uses the signed-in alias ahead of stored and backend operators when launching', async () => { @@ -235,8 +239,9 @@ describe('Shared new run labels', () => { username: 'Signed.In@contoso.com', tenantId: 'tenant', homeAccountId: 'signed-in', }) renderApp() - const operator = await screen.findByRole('button', { name: 'Signed-in operator: signed.in' }) - expect(operator).toHaveAttribute('aria-disabled', 'true') + const operator = await screen.findByRole('textbox', { name: 'Signed-in operator' }) + expect(operator).toHaveValue('signed.in') + expect(operator).toHaveAttribute('readonly') await user.click(operator) expect(screen.queryByRole('textbox', { name: 'Value for operator label' })).not.toBeInTheDocument() await chooseOperation(user, 'signed_in_op') @@ -281,15 +286,15 @@ describe('Shared new run labels', () => { renderApp() await chooseOperation(user, 'early_choice') await act(async () => { resolveVersion({ version: '1.0.0', default_labels: DEFAULT_LABELS }) }) - await screen.findByRole('button', { name: 'Edit operator, currently config_user' }) - expect(screen.getByRole('button', { name: /currently early_choice$/ })).toBeInTheDocument() + await screen.findByRole('textbox', { name: 'Operator' }) + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('early_choice') }) it('leaves saved scenario attribution unchanged when future launch labels change', async () => { const user = userEvent.setup() renderApp('/scanner-history/saved_run') const saved = screen.getByRole('region', { name: 'Run configuration' }) - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByRole('combobox', { name: 'Operation' }) await chooseOperation(user, 'future_op') expect(saved).toHaveTextContent('original_user') expect(saved).toHaveTextContent('original_op') @@ -347,7 +352,7 @@ describe('Shared new run labels', () => { await user.click(within(toolbar).getByRole('button', { name: 'New Attack' })) expect(await screen.findByRole('button', { name: 'New Attack' })).toBeDisabled() expect(screen.queryByTestId('operator-locked-banner')).not.toBeInTheDocument() - expect(within(currentLabels()).getByRole('button', { name: /currently future_op$/ })).toBeInTheDocument() + expect(within(currentLabels()).getByRole('combobox', { name: 'Operation' })).toHaveValue('future_op') }) describe('runtime generation defaults', () => { @@ -374,18 +379,16 @@ describe('Shared new run labels', () => { await screen.findByText(/PyRIT runtime: unavailable/) expect(screen.getByRole('button', { name: 'Home', exact: true })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Edit operation, currently config_op' })) - .not.toBeInTheDocument() + expect(screen.queryByDisplayValue('config_op')).not.toBeInTheDocument() await pollGeneration('gen-1') - expect(await screen.findByRole('button', { name: 'Edit operation, currently config_op' })) - .toBeInTheDocument() + expect(await screen.findByDisplayValue('config_op')).toBeInTheDocument() }) it('refetches once per generation and keeps user overrides on launch', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) renderApp() - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByDisplayValue('config_op') await chooseOperation(user, 'user_op') const callsAfterFirstLoad = jest.mocked(versionApi.getVersion).mock.calls.length @@ -396,7 +399,7 @@ describe('Shared new run labels', () => { await pollGeneration('gen-2') expect(versionApi.getVersion).toHaveBeenCalledTimes(callsAfterFirstLoad + 1) - expect(screen.getByRole('button', { name: 'Edit operation, currently user_op' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('user_op') await launchScenario(user) expect(scenariosApi.startRun).toHaveBeenCalledWith(expect.objectContaining({ labels: { ...DEFAULT_LABELS, operation: 'user_op' }, @@ -407,7 +410,7 @@ describe('Shared new run labels', () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) let resolveVersion: (value: VersionResponse) => void = () => {} renderApp() - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByDisplayValue('config_op') await user.selectOptions(screen.getByRole('combobox', { name: 'Objective Target' }), 'test_target') expect(screen.getByRole('button', { name: 'Launch scan' })).toBeEnabled() @@ -421,7 +424,7 @@ describe('Shared new run labels', () => { resolveVersion({ version: '1.0.0', default_labels: { ...DEFAULT_LABELS, operation: 'config_op_v2' } }) }) - expect(screen.getByRole('button', { name: 'Edit operation, currently config_op_v2' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('config_op_v2') await launchScenario(user) expect(scenariosApi.startRun).toHaveBeenCalledWith(expect.objectContaining({ labels: { ...DEFAULT_LABELS, operation: 'config_op_v2' }, @@ -439,7 +442,7 @@ describe('Shared new run labels', () => { } renderApp() if (phase === 'generation refresh') { - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByDisplayValue('config_op') jest.mocked(versionApi.getVersion).mockRejectedValue(failure) await pollGeneration('gen-2') } @@ -465,7 +468,7 @@ describe('Shared new run labels', () => { }) expect(screen.queryByText(/Could not load default labels/)).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Edit operation, currently user_op' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('user_op') await launchScenario(user) expect(scenariosApi.startRun).toHaveBeenCalledWith(expect.objectContaining({ labels: { ...DEFAULT_LABELS, operation: 'user_op', team: 'new_team' }, @@ -477,7 +480,7 @@ describe('Shared new run labels', () => { let resolveVersion: (value: VersionResponse) => void = () => {} let rejectVersion: (error: Error) => void = () => {} renderApp() - await screen.findByRole('button', { name: 'Edit operation, currently config_op' }) + await screen.findByDisplayValue('config_op') jest.mocked(versionApi.getVersion).mockReturnValueOnce(new Promise((resolve, reject) => { resolveVersion = resolve rejectVersion = reject @@ -496,7 +499,7 @@ describe('Shared new run labels', () => { } }) - expect(screen.getByRole('button', { name: 'Edit operation, currently config_op_v3' })).toBeInTheDocument() + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('config_op_v3') expect(screen.queryByText(/Could not load default labels/)).not.toBeInTheDocument() await launchScenario(user) expect(scenariosApi.startRun).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f86bddcfbf..b22bc15e1c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -726,6 +726,20 @@ describe("App", () => { expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument(); }); + it("carries the exact operation across history tabs", async () => { + const operation = "Red team / α% &"; + renderApp(`/history/attacks?operation=${encodeURIComponent(operation)}&outcome=success`); + fireEvent.click(screen.getByRole("tab", { name: "Scanner" })); + const scanner = await screen.findByTestId("scenario-history"); + const url = new URL(scanner.getAttribute("data-location") ?? "", "http://localhost"); + expect(url.pathname).toBe("/history/scanner"); + expect(url.searchParams.get("operation")).toBe(operation); + expect(url.searchParams.has("outcome")).toBe(false); + fireEvent.click(screen.getByRole("tab", { name: "Attacks" })); + expect(await screen.findByTestId("history-filters")).toHaveTextContent(operation); + expect(screen.getByTestId("history-filters")).toHaveTextContent("success"); + }); + it("switches between history tabs", async () => { renderApp("/history/attacks"); @@ -737,6 +751,30 @@ describe("App", () => { ); }); + it("clears the remembered operation while preserving each tab's other filters", async () => { + function HistoryNavigation() { + const navigate = useNavigate(); + return ; + } + render( + + + + , + ); + await screen.findByTestId("scenario-history"); + fireEvent.click(screen.getByRole("button", { name: "Clear operation via URL" })); + await screen.findByTestId("attack-history"); + fireEvent.click(screen.getByRole("tab", { name: "Scanner" })); + const scanner = await screen.findByTestId("scenario-history"); + const url = new URL(scanner.getAttribute("data-location") ?? "", "http://localhost"); + expect(url.searchParams.has("operation")).toBe(false); + expect(url.searchParams.get("operator")).toBe("alice"); + fireEvent.click(screen.getByRole("tab", { name: "Attacks" })); + expect(await screen.findByTestId("history-filters")).toHaveTextContent("success"); + expect(screen.getByTestId("history-filters")).not.toHaveTextContent("alice"); + }); + it("passes the active target and labels to the scenario detail view", () => { renderApp("/scanner/foundry.red_team_agent"); @@ -978,6 +1016,23 @@ describe("App", () => { expect(screen.queryByTestId("chat-window")).not.toBeInTheDocument(); }); + it.each([404, 503])("distinguishes evidence-origin attack load status %s", async (status: number) => { + mockGetAttack.mockRejectedValue({ isAxiosError: true, response: { status, data: {} } }); + renderApp("/attacks/owner/conversations/source?findingEvidenceId=123e4567-e89b-12d3-a456-426614174000"); + expect(await screen.findByText(status === 404 ? "Evidence unavailable" : "Could not load attack")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-window")).not.toBeInTheDocument(); + }); + + it("never redirects a missing evidence conversation to the attack main conversation", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "owner", conversation_id: "main", related_conversation_ids: [], + labels: {}, objective: "", outcome: "undetermined", + }); + renderApp("/attacks/owner/conversations/source?findingEvidenceId=123e4567-e89b-12d3-a456-426614174000"); + expect(await screen.findByText("Evidence unavailable")).toBeInTheDocument(); + expect(screen.queryByTestId("chat-window")).not.toBeInTheDocument(); + }); + it("shows the error UX (not not-found) when an attack load fails with a non-404", async () => { // A 500 / network / timeout is transient and must not claim the attack was deleted. mockGetAttack.mockRejectedValue({ isAxiosError: true, response: { status: 500, data: {} } }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e110b0e8c1..74a513b1b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,8 @@ import RegistryLayout from './components/Registry/RegistryLayout' import Configuration from './components/Configuration/Configuration' import AttackHistory from './components/History/AttackHistory' import HistoryPage from './components/History/HistoryPage' +import OperationDetailPage from './components/Operations/OperationDetailPage' +import OperationsPage from './components/Operations/OperationsPage' import type { HistoryTab } from './components/History/HistoryPage' import ScenarioHistory from './components/History/ScenarioHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' @@ -55,6 +57,7 @@ import { attackRoutePath, routerPathParamValue, scenarioRunProvenance, + findingEvidenceOrigin, scenarioRunRoutePath, } from './utils/routeParams' @@ -67,6 +70,7 @@ const VIEW_PATHS: Record = { home: '/', chat: '/chat', history: HISTORY_ATTACKS_PATH, + operations: '/operations', registry: '/registry/targets', scenarios: '/scanner', configuration: '/config', @@ -85,6 +89,9 @@ function viewFromPath(pathname: string): ViewName { if (pathname === '/targets' || pathname.startsWith('/registry')) { return 'registry' } + if (pathname.startsWith(`${VIEW_PATHS.operations}/`)) { + return 'operations' + } if ( pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) @@ -263,6 +270,10 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { () => scenarioRunProvenance(searchParams), [searchParams], ) + const findingEvidenceId = useMemo( + () => findingEvidenceOrigin(searchParams), + [searchParams], + ) const lastHistorySearch = useRef('') const lastScenarioHistorySearch = useRef('') useEffect(() => { @@ -285,8 +296,14 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { const handleHistoryTabChange = useCallback((tab: HistoryTab) => { const path = tab === 'attacks' ? HISTORY_ATTACKS_PATH : HISTORY_SCANNER_PATH const search = tab === 'attacks' ? lastHistorySearch.current : lastScenarioHistorySearch.current - navigate(path + search) - }, [navigate]) + const params = new URLSearchParams(search) + params.delete('operation') + for (const operation of searchParams.getAll('operation')) { + params.append('operation', operation) + } + const query = params.toString() + navigate(path + (query ? `?${query}` : '')) + }, [navigate, searchParams]) /** App version display, attached to feedback context */ const [appVersion, setAppVersion] = useState('') @@ -427,6 +444,14 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { const readyAttack = attackForRoute?.status === 'success' ? attackForRoute : null const isAttackNotFound = attackForRoute?.status === 'not-found' const isAttackError = attackForRoute?.status === 'error' + const isEvidenceConversationMissing = useMemo( + () => Boolean( + findingEvidenceId && readyAttack && routeConversationId + && routeConversationId !== readyAttack.mainConversationId + && !readyAttack.relatedConversationIds.includes(routeConversationId), + ), + [findingEvidenceId, readyAttack, routeConversationId], + ) const isLoadingAttack = isNavigatingToCreatedAttack || (routeAttackId !== null && !readyAttack && !isAttackNotFound && !isAttackError) const { @@ -464,11 +489,11 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { const isKnown = routeConversationId === readyAttack.mainConversationId || readyAttack.relatedConversationIds.includes(routeConversationId) - if (!isKnown) { + if (!isKnown && !findingEvidenceId) { navigate(attackRoutePath(readyAttack.id, scenarioResultId), { replace: true }) } } - }, [readyAttack, routeConversationId, navigate, scenarioResultId]) + }, [readyAttack, routeConversationId, navigate, scenarioResultId, findingEvidenceId]) const handleNavigate = useCallback((view: ViewName) => { // Re-attach the last filter query so returning to history restores filters. @@ -579,10 +604,11 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { }) }, [location.search, navigate]) - const chatElement = isAttackNotFound || isAttackError ? ( + const chatElement = isAttackNotFound || isAttackError || isEvidenceConversationMissing ? ( navigate(VIEW_PATHS.chat)} onBackToHistory={() => navigate(VIEW_PATHS.history)} /> @@ -626,6 +652,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { humanScore={readyAttack?.humanScore} lastResponseMessagePieceId={readyAttack?.lastResponseMessagePieceId} scenarioResultId={readyAttack ? scenarioResultId : null} + findingEvidenceId={findingEvidenceId} /> ) @@ -760,6 +787,9 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { } /> } /> } /> + } /> + } /> + } /> { + it("shows evidence unavailable only for evidence-origin not-found", () => { + renderNotFound({ findingEvidenceId: "123e4567-e89b-12d3-a456-426614174000" }); + expect(screen.getByText("Evidence unavailable")).toBeInTheDocument(); + }); + it("keeps network errors distinct even for evidence origin", () => { + renderNotFound({ findingEvidenceId: "123e4567-e89b-12d3-a456-426614174000", variant: "error" }); + expect(screen.getByText("Could not load attack")).toBeInTheDocument(); + expect(screen.queryByText("Evidence unavailable")).not.toBeInTheDocument(); + }); it("shows the missing attack id", () => { renderNotFound(); expect(screen.getByTestId("attack-not-found")).toBeInTheDocument(); diff --git a/frontend/src/components/Chat/AttackNotFound.tsx b/frontend/src/components/Chat/AttackNotFound.tsx index 1f47b3f2a8..d26841259b 100644 --- a/frontend/src/components/Chat/AttackNotFound.tsx +++ b/frontend/src/components/Chat/AttackNotFound.tsx @@ -7,6 +7,7 @@ interface AttackNotFoundProps { onBackToHistory: () => void /** 'not-found' for a genuine 404; 'error' for a transient load failure. */ variant?: 'not-found' | 'error' + findingEvidenceId?: string | null } export default function AttackNotFound({ @@ -14,6 +15,7 @@ export default function AttackNotFound({ onStartNew, onBackToHistory, variant = 'not-found', + findingEvidenceId, }: AttackNotFoundProps) { const styles = useAttackNotFoundStyles() const isError = variant === 'error' @@ -21,7 +23,7 @@ export default function AttackNotFound({ return (
- {isError ? 'Could not load attack' : 'Attack not found'} + {isError ? 'Could not load attack' : findingEvidenceId ? 'Evidence unavailable' : 'Attack not found'} {isError ? ( diff --git a/frontend/src/components/Chat/ChatWindow.styles.ts b/frontend/src/components/Chat/ChatWindow.styles.ts index 57756b3b78..6bf331d584 100644 --- a/frontend/src/components/Chat/ChatWindow.styles.ts +++ b/frontend/src/components/Chat/ChatWindow.styles.ts @@ -127,6 +127,18 @@ export const useChatWindowStyles = makeStyles({ ribbonAction: { ...mobileTouchTarget, }, + conversationActions: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + paddingInline: tokens.spacingHorizontalS, + borderInlineStartWidth: '1px', + borderInlineStartStyle: 'solid', + borderInlineStartColor: tokens.colorNeutralStroke2, + borderInlineEndWidth: '1px', + borderInlineEndStyle: 'solid', + borderInlineEndColor: tokens.colorNeutralStroke2, + }, newAttackButton: { flexShrink: 0, [NARROW_VIEWPORT_QUERY]: { diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 8adfbacbb9..cf8af77e1a 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -29,6 +29,15 @@ import { import { attacksApi, convertersApi, scoresApi } from "../../services/api"; import * as messageMapper from "../../utils/messageMapper"; +jest.mock("./FindingEvidenceDialog", () => ({ + __esModule: true, + default: ({ attackResultId, conversationId, disabled }: { + attackResultId: string; conversationId: string; disabled: boolean; + }) => attackResultId && conversationId ? : null, +})); + const buildCapabilities = ( overrides: Partial = {} ): TargetCapabilities => ({ @@ -420,6 +429,60 @@ describe("ChatWindow Integration", () => { await user.keyboard("{Escape}"); } + it("provides the persisted viewed identity to the attachment action, not the toolbar Operation", async () => { + mockedAttacksApi.getMessages.mockResolvedValue(makeTextResponse("Saved").messages); + mockedMapper.backendMessagesToFrontend.mockReturnValue([{ role: "user", content: "Saved", timestamp: "" }]); + render(); + const action = await screen.findByRole("button", { name: "Link to finding" }); + await waitFor(() => { expect(action).toBeEnabled(); }); + expect(action).toHaveAttribute("data-attack", "owner"); + expect(action).toHaveAttribute("data-conversation", "related"); + const conversationActions = screen.getByRole("group", { name: "Conversation actions" }); + expect(within(conversationActions).getByRole("button", { name: "Link to finding" })).toBe(action); + expect(within(conversationActions).getByRole("button", { name: "Export conversation" })).toBeInTheDocument(); + expect(within(conversationActions).getByRole("button", { name: "Toggle conversations panel" })).toBeInTheDocument(); + expect(within(conversationActions).queryByRole("switch")).not.toBeInTheDocument(); + expect(within(conversationActions).queryByRole("button", { name: "New Attack" })).not.toBeInTheDocument(); + }); + + it("keeps the attachment workflow mounted across viewed conversation changes so pending creation can recover", async () => { + mockedAttacksApi.getMessages.mockResolvedValue(makeTextResponse("Saved").messages); + mockedMapper.backendMessagesToFrontend.mockReturnValue([{ role: "user", content: "Saved", timestamp: "" }]); + const rendered = render(); + const action = await screen.findByRole("button", { name: "Link to finding" }); + await waitFor(() => { expect(action).toBeEnabled(); }); + rendered.rerender(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Link to finding" })).toHaveAttribute("data-conversation", "another"); + }); + expect(screen.getByRole("button", { name: "Link to finding" })).toBe(action); + }); + + it.each([404, 503])("distinguishes evidence-origin message load status %s", async (status: number) => { + mockedAttacksApi.getMessages.mockRejectedValue({ isAxiosError: true, response: { status, data: { detail: "source load failed" } } }); + render(); + if (status === 404) { + expect(await screen.findByText("Evidence unavailable")).toBeInTheDocument(); + } else { + expect(await screen.findByText(/Could not load conversation/)).toBeInTheDocument(); + expect(screen.queryByText("Evidence unavailable")).not.toBeInTheDocument(); + } + }); + + it("reports an evidence-origin successfully empty transcript as unavailable", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ conversation_id: "main", messages: [], target_response_status: null }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([]); + render(); + expect(await screen.findByText("Evidence unavailable")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Link to finding" })).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeDisabled(); + }); + beforeEach(() => { jest.clearAllMocks(); mockedAttacksApi.getMessages.mockReset(); diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 157098f6f9..accf80327c 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -30,6 +30,7 @@ import MessageList from './MessageList' import ChatInputArea from './ChatInputArea' import MultiSendProgress from './MultiSendProgress' import ConversationPanel from './ConversationPanel' +import FindingEvidenceDialog from './FindingEvidenceDialog' import ConverterPanel from './ConverterPanel' import TargetBadge from './TargetBadge' import ChatTargetPicker from './ChatTargetPicker' @@ -298,6 +299,7 @@ interface ChatWindowProps { lastResponseMessagePieceId?: string | null /** Validated scenario-run provenance for attacks opened from a run dashboard. */ scenarioResultId?: string | null + findingEvidenceId?: string | null } export default function ChatWindow({ @@ -333,11 +335,15 @@ export default function ChatWindow({ humanScore, lastResponseMessagePieceId, scenarioResultId, + findingEvidenceId, }: ChatWindowProps) { const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() const restoreFocusSourceAttributes = useRestoreFocusSource() const [messages, setMessages] = useState([]) + const [conversationLoadIssue, setConversationLoadIssue] = useState<{ + conversationId: string; unavailable: boolean; detail: string; + } | null>(null) const [pendingObjective, setPendingObjective] = useState('') const currentObjective = attackResultId ? objective : pendingObjective const runtime = useRuntime() @@ -392,6 +398,10 @@ export default function ChatWindow({ discardEditor() } }, [editDraft, viewedConversationId, attackResultId, discardEditor]) + const isEvidenceUnavailable = Boolean( + findingEvidenceId && conversationLoadIssue?.conversationId === viewedConversationId + && conversationLoadIssue?.unavailable, + ) const savedRecovery = viewedConversationId ? recoverableSends[viewedConversationId] : undefined @@ -519,7 +529,7 @@ export default function ChatWindow({ && !targetInfoMatchesTarget(attackTarget, activeTarget), ) // Any failed invariant keeps all mutation controls and handlers read-only. - const isMutationLocked = isOperatorLocked || isCrossTargetLocked || isTargetResolutionLocked + const isMutationLocked = isOperatorLocked || isCrossTargetLocked || isTargetResolutionLocked || isEvidenceUnavailable // Clear internal messages when attack state is reset (e.g. New Attack). // Uses the "adjust state during render" pattern (see React docs: @@ -559,6 +569,11 @@ export default function ChatWindow({ const response = await attacksApi.getMessages(arId, convId) // Discard superseded loads and responses invalidated by a send. if (!isCurrentLoad() || viewedConvRef.current !== convId) { return } + setConversationLoadIssue( + findingEvidenceId && response.messages.length === 0 + ? { conversationId: convId, unavailable: true, detail: 'Evidence unavailable' } + : null, + ) const frontendMessages = backendMessagesToFrontend(response.messages) const savedUserIds = userPieceIds(response) loadedUserPieceIdsRef.current.set(convId, savedUserIds) @@ -611,8 +626,15 @@ export default function ChatWindow({ } setMessages(frontendMessages) markConversationLoaded(convId) - } catch { + } catch (cause: unknown) { if (!isCurrentLoad() || viewedConvRef.current !== convId) { return } + if (findingEvidenceId) { + const error = toApiError(cause) + setConversationLoadIssue({ + conversationId: convId, unavailable: error.status === 404, + detail: error.status === 404 ? 'Evidence unavailable' : `Could not load conversation: ${error.detail}`, + }) + } // Initial-load failures must not show another conversation's transcript. // Refresh failures keep the already-loaded transcript and recovery aligned. if (loadedConversationIdRef.current !== convId) { @@ -628,7 +650,7 @@ export default function ChatWindow({ setIsLoadingMessages(false) } } - }, [markConversationLoaded]) + }, [markConversationLoaded, findingEvidenceId]) // Reload messages when activeConversationId changes useEffect(() => { @@ -656,7 +678,8 @@ export default function ChatWindow({ activeConversationId && activeConversationId !== loadedConversationId && !sendingConversations.has(activeConversationId) ) - const isScoreLocked = isOperatorLocked || Boolean(isLoadingAttack) || isLoadingMessages || awaitingConversationLoad + const isScoreLocked = isOperatorLocked || Boolean(isLoadingAttack) || isLoadingMessages + || awaitingConversationLoad || isEvidenceUnavailable // Handle conversation selection from the panel // For a different ID the useEffect handles loading; for same ID force a refresh @@ -1630,51 +1653,57 @@ export default function ChatWindow({ data-testid="global-markdown-toggle" /> - - - - - -
+ )} + + + )} {editDraft === null && { void copyConversation(index, 'same_attack') }} onCopyToNewAttack={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'new_attack') }} diff --git a/frontend/src/components/Chat/FindingEvidenceDialog.styles.ts b/frontend/src/components/Chat/FindingEvidenceDialog.styles.ts new file mode 100644 index 0000000000..aca7828871 --- /dev/null +++ b/frontend/src/components/Chat/FindingEvidenceDialog.styles.ts @@ -0,0 +1,14 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget } from '@/styles/touchTargets' + +export const useFindingEvidenceDialogStyles = makeStyles({ + content: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }, + pickerHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: tokens.spacingHorizontalM }, + recovery: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS }, + list: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS }, + context: { display: 'block', color: tokens.colorNeutralForeground3 }, + findingHeading: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: tokens.spacingHorizontalS }, + pagination: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalS }, + button: { ...mobileTouchTarget }, +}) diff --git a/frontend/src/components/Chat/FindingEvidenceDialog.test.tsx b/frontend/src/components/Chat/FindingEvidenceDialog.test.tsx new file mode 100644 index 0000000000..2c9050b073 --- /dev/null +++ b/frontend/src/components/Chat/FindingEvidenceDialog.test.tsx @@ -0,0 +1,406 @@ +import React from 'react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter } from 'react-router' + +import { attacksApi, operationsApi } from '@/services/api' +import type { FindingListItem } from '@/types' +import FindingEvidenceDialog from './FindingEvidenceDialog' + +jest.mock('@/services/api', () => ({ + attacksApi: { getAttack: jest.fn() }, + operationsApi: { + list: jest.fn(), searchFindings: jest.fn(), attachFindingEvidence: jest.fn(), createFinding: jest.fn(), + getFindingOptions: jest.fn(), + }, +})) + +const FINDING: FindingListItem = { + id: 'finding', operation_id: 'saved', title: 'Proof', severity: 'low', description: '', + created_at: '2026-10-08T12:00:00Z', evidence_count: 0, +} +const EMPTY = { items: [], has_more: false, next_offset: null } + +function renderDialog(): void { + render( + + ) +} + +beforeEach(() => { + jest.clearAllMocks() + for (const method of Object.values(operationsApi)) jest.mocked(method).mockReset() + jest.mocked(attacksApi.getAttack).mockReset() + ;(attacksApi.getAttack as jest.Mock).mockResolvedValue({ operation: 'Exact' }) + ;(operationsApi.list as jest.Mock).mockResolvedValue({ + items: [{ id: 'saved', name: 'Exact' }], + }) + ;(operationsApi.searchFindings as jest.Mock).mockResolvedValue({ ...EMPTY, items: [FINDING] }) + ;(operationsApi.attachFindingEvidence as jest.Mock).mockResolvedValue({ item: { id: 'stable' }, created: true }) + jest.mocked(operationsApi.createFinding).mockResolvedValue(FINDING) + jest.mocked(operationsApi.getFindingOptions).mockResolvedValue({ harm_types: ['Malware', 'Other'] }) +}) + +it('resolves only persisted exact attribution and attaches the viewed related conversation', async () => { + const user = userEvent.setup() + renderDialog() + const action = await screen.findByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + await user.click(action) + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + await user.click(screen.getByRole('button', { name: 'Attach conversation' })) + expect(await screen.findByText('Attached')).toBeInTheDocument() + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + await waitFor(() => { expect(action).toHaveFocus() }) + expect(attacksApi.getAttack).toHaveBeenCalledWith('viewed') + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith('saved', 'finding', { + attack_result_id: 'viewed', conversation_id: 'related', + }) +}) + +it.each([null, 'exact', 'Exact '])('explains ineligibility for attribution %s', async (operation: string | null) => { + ;(attacksApi.getAttack as jest.Mock).mockResolvedValue({ operation }) + renderDialog() + expect(await screen.findByText(/no saved Operation|does not match a saved Operation/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Link to finding' })).toBeDisabled() +}) + +it('distinguishes lookup failure from ineligibility and retries', async () => { + const user = userEvent.setup() + ;(attacksApi.getAttack as jest.Mock).mockRejectedValueOnce(new Error('offline')) + renderDialog() + expect(await screen.findByText(/Could not resolve Operation/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry Operation lookup' })) + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) +}) + +it('searches literally, resets pagination, and ignores superseded results', async () => { + const user = userEvent.setup() + renderDialog() + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + await screen.findByRole('radio', { name: /Proof/ }) + let resolveOld: (value: typeof EMPTY) => void = () => {} + ;(operationsApi.searchFindings as jest.Mock).mockImplementationOnce( + () => new Promise(resolve => { resolveOld = resolve }), + ) + await user.type(screen.getByRole('textbox', { name: 'Search findings by title' }), '%') + await user.type(screen.getByRole('textbox', { name: 'Search findings by title' }), '_') + await screen.findByRole('radio', { name: /Proof/ }) + await act(async () => { resolveOld(EMPTY) }) + expect(screen.getByRole('radio', { name: /Proof/ })).toBeInTheDocument() + expect(operationsApi.searchFindings).toHaveBeenLastCalledWith('saved', { limit: 20, offset: 0, title: '%_' }) +}) + +it('preserves search and selection on failure, reports already attached, and restores focus on cancel', async () => { + const user = userEvent.setup() + ;(operationsApi.attachFindingEvidence as jest.Mock).mockRejectedValueOnce(new Error('failed')) + renderDialog() + const action = screen.getByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + await user.click(action) + await user.type(screen.getByRole('textbox', { name: 'Search findings by title' }), 'Proof') + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + await user.click(screen.getByRole('button', { name: 'Attach conversation' })) + expect(await screen.findByText(/Could not attach/)).toBeInTheDocument() + expect(screen.getByRole('textbox')).toHaveValue('Proof') + expect(screen.getByRole('radio')).toBeChecked() + ;(operationsApi.attachFindingEvidence as jest.Mock).mockResolvedValueOnce({ item: { id: 'stable' }, created: false }) + await user.click(screen.getByRole('button', { name: 'Attach conversation' })) + expect(await screen.findByText('Already attached')).toBeInTheDocument() + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + await waitFor(() => { expect(action).toHaveFocus() }) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledTimes(2) +}) + +it('offers creation inside the empty picker and retains the Operation link', async () => { + const user = userEvent.setup() + ;(operationsApi.searchFindings as jest.Mock).mockResolvedValue(EMPTY) + renderDialog() + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + expect(await screen.findByText(/No findings in this Operation yet/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open Operation' })).toHaveAttribute('href', '/operations/saved') + expect(within(screen.getByRole('dialog')).getByRole('button', { name: 'New finding' })).toBeEnabled() +}) + +async function openCreation(user: ReturnType): Promise { + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + await user.click(await screen.findByRole('button', { name: 'New finding' })) + await screen.findByRole('heading', { name: 'New finding' }) +} + +it('creates in the owning Operation then attaches the viewed entire conversation and closes both dialogs', async () => { + const user = userEvent.setup() + renderDialog() + await openCreation(user) + expect(screen.getByRole('combobox', { name: 'Severity' })).toHaveValue('moderate') + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + await user.type(screen.getByRole('textbox', { name: 'Title' }), ' ') + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'New assessment') + await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), 'important') + await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Optional notes') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText(/Finding created and conversation attached/)).toBeInTheDocument() + expect(operationsApi.createFinding).toHaveBeenCalledWith('saved', { + title: ' New assessment', severity: 'important', description: 'Optional notes', + severity_other: null, harm_type: null, harm_type_other: null, + }) + + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith('saved', 'finding', { + attack_result_id: 'viewed', conversation_id: 'related', + }) + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toHaveFocus() }) +}) + +it('creates and attaches custom classifications from the same shared form and shows them in the picker', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.searchFindings).mockResolvedValue({ ...EMPTY, items: [{ + ...FINDING, severity: 'other', severity_other: 'Team severity', harm_type: 'Other', harm_type_other: 'Team harm', + }] }) + renderDialog() + await waitFor(() => expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + expect(await screen.findByText('Team severity')).toBeInTheDocument() + expect(screen.getByText('Harm-type: Team harm')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'New finding' })) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Custom') + await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), 'other') + await user.type(screen.getByRole('textbox', { name: 'Other severity' }), 'Team severity') + await user.click(screen.getByRole('combobox', { name: 'Harm-type' })) + await user.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Other', exact: true })) + await user.type(screen.getByRole('textbox', { name: 'Other harm-type' }), 'Team harm') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText(/Finding created and conversation attached/)).toBeInTheDocument() + expect(operationsApi.createFinding).toHaveBeenCalledWith('saved', { + title: 'Custom', description: '', severity: 'other', severity_other: 'Team severity', + harm_type: 'Other', harm_type_other: 'Team harm', + }) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith('saved', 'finding', { + attack_result_id: 'viewed', conversation_id: 'related', + }) +}) + +it.each(['Cancel', 'Escape'])('discards new drafts with %s and restores picker focus', async dismiss => { + const user = userEvent.setup() + renderDialog() + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Discard') + if (dismiss === 'Escape') await user.keyboard('{Escape}') + else await user.click(screen.getByRole('button', { name: 'Cancel' })) + const newFinding = await screen.findByRole('button', { name: 'New finding' }) + await waitFor(() => { expect(newFinding).toHaveFocus() }) + await user.click(newFinding) + expect(await screen.findByRole('textbox', { name: 'Title' })).toHaveValue('') + expect(operationsApi.createFinding).not.toHaveBeenCalled() + expect(operationsApi.attachFindingEvidence).not.toHaveBeenCalled() +}) + +it('retains the draft after creation fails without attempting attachment', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.createFinding).mockRejectedValueOnce(new Error('Create failed')) + renderDialog() + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Retain draft') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText('Create failed')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Title' })).toHaveValue('Retain draft') + expect(operationsApi.attachFindingEvidence).not.toHaveBeenCalled() +}) + +it('exposes the saved finding after attachment fails and retries attachment only even after dismissal', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.attachFindingEvidence).mockRejectedValueOnce(new Error('Attach failed')) + renderDialog() + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Created once') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText(/Finding created, but conversation attachment failed/)).toHaveTextContent('Attach failed') + expect(screen.getByText(/Finding ID: finding/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open saved finding Operation' })).toHaveAttribute('href', '/operations/saved') + expect(screen.queryByRole('button', { name: 'Save finding' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(await screen.findByText(/Finding created, but conversation attachment failed/)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry attachment' })) + await user.click(await screen.findByRole('button', { name: 'Retry attachment' })) + expect(await screen.findByText(/Finding created and conversation attached/)).toBeInTheDocument() + expect(operationsApi.createFinding).toHaveBeenCalledTimes(1) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledTimes(2) + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toHaveFocus() }) +}) + +it('guards duplicate saves and cancellation while creating', async () => { + const user = userEvent.setup() + let finish: (value: typeof FINDING) => void = () => {} + jest.mocked(operationsApi.createFinding).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + renderDialog() + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Only once') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + await user.click(screen.getByRole('button', { name: 'Saving…' })) + await user.keyboard('{Escape}') + expect(screen.getByRole('textbox', { name: 'Title' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled() + expect(operationsApi.createFinding).toHaveBeenCalledTimes(1) + await act(async () => { finish(FINDING) }) + expect(await screen.findByText(/Finding created and conversation attached/)).toBeInTheDocument() +}) + +it('does not attach after viewed identity changes while creation is pending and exposes the saved ID', async () => { + const user = userEvent.setup() + let finish: (value: typeof FINDING) => void = () => {} + jest.mocked(operationsApi.createFinding).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const { rerender } = render( + + ) + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Captured identity') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + rerender( + + ) + await act(async () => { finish(FINDING) }) + expect(await screen.findByText(/Finding created, but conversation attachment failed/)).toHaveTextContent(/viewed conversation changed/i) + expect(screen.getByText(/Finding ID: finding/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry attachment' })).toBeDisabled() + expect(operationsApi.attachFindingEvidence).not.toHaveBeenCalled() + expect(operationsApi.createFinding).toHaveBeenCalledTimes(1) +}) + +it('does not retarget an unsaved creation draft when the viewed conversation changes', async () => { + const user = userEvent.setup() + const { rerender } = render( + + ) + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Original source') + rerender( + + ) + expect(await screen.findByText(/viewed conversation changed/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + expect(screen.getByRole('textbox', { name: 'Title' })).toHaveValue('Original source') + expect(operationsApi.createFinding).not.toHaveBeenCalled() +}) + +it('does not announce attachment failure while the attachment is still pending', async () => { + const user = userEvent.setup() + let finish: (value: { item: { id: string }; created: boolean }) => void = () => {} + ;(operationsApi.attachFindingEvidence as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + renderDialog() + await openCreation(user) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Wait for attachment') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + await waitFor(() => { expect(operationsApi.attachFindingEvidence).toHaveBeenCalledTimes(1) }) + expect(screen.queryByText(/Finding created, but conversation attachment failed/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true') + await act(async () => { finish({ item: { id: 'stable' }, created: true }) }) + expect(await screen.findByText(/Finding created and conversation attached/)).toBeInTheDocument() +}) + +it('paginates existing findings and resets to the first page on a literal search', async () => { + const user = userEvent.setup() + ;(operationsApi.searchFindings as jest.Mock).mockResolvedValueOnce({ + items: [FINDING], has_more: true, next_offset: 20, + }) + renderDialog() + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + await screen.findByRole('radio') + await user.click(screen.getByRole('button', { name: 'Next findings' })) + await waitFor(() => { + expect(operationsApi.searchFindings).toHaveBeenLastCalledWith('saved', { limit: 20, offset: 20, title: '' }) + }) + await screen.findByRole('radio') + await user.type(screen.getByRole('textbox'), '%_') + await waitFor(() => { + expect(operationsApi.searchFindings).toHaveBeenLastCalledWith('saved', { limit: 20, offset: 0, title: '%_' }) + }) +}) + +it('blocks duplicate submission and changes to the selected identity while attaching', async () => { + const user = userEvent.setup() + let finish: (value: { item: { id: string }; created: boolean }) => void = () => {} + ;(operationsApi.attachFindingEvidence as jest.Mock).mockImplementationOnce( + () => new Promise(resolve => { finish = resolve }), + ) + renderDialog() + await waitFor(() => { expect(screen.getByRole('button', { name: 'Link to finding' })).toBeEnabled() }) + await user.click(screen.getByRole('button', { name: 'Link to finding' })) + await user.click(await screen.findByRole('radio')) + await user.click(screen.getByRole('button', { name: 'Attach conversation' })) + const busyAction = screen.getByRole('button', { name: 'Attaching...' }) + await user.click(busyAction) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledTimes(1) + expect(screen.getByRole('textbox')).toBeDisabled() + expect(screen.getByRole('radio')).toBeDisabled() + await act(async () => { finish({ item: { id: 'stable' }, created: true }) }) + expect(await screen.findByText('Attached')).toBeInTheDocument() +}) + +it('does not keep a cancelled selection when the picker reopens', async () => { + const user = userEvent.setup() + renderDialog() + const action = screen.getByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + await user.click(action) + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + await user.click(action) + expect(await screen.findByRole('radio', { name: /Proof/ })).not.toBeChecked() + expect(screen.getByRole('button', { name: 'Attach conversation' })).toBeDisabled() +}) + +it('drops the selection when the viewed conversation changes while the picker is open', async () => { + const user = userEvent.setup() + const { rerender } = render( + + ) + const action = screen.getByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + await user.click(action) + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + rerender( + + ) + expect(await screen.findByRole('radio', { name: /Proof/ })).not.toBeChecked() + expect(screen.getByRole('button', { name: 'Attach conversation' })).toBeDisabled() + expect(operationsApi.attachFindingEvidence).not.toHaveBeenCalled() +}) + +it('cancel makes no attachment request and returns keyboard focus', async () => { + const user = userEvent.setup() + renderDialog() + const action = screen.getByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + await user.click(action) + await screen.findByRole('radio') + await user.keyboard('{Escape}') + await waitFor(() => { expect(action).toHaveFocus() }) + expect(operationsApi.attachFindingEvidence).not.toHaveBeenCalled() +}) + +it('uses an accessible icon trigger and a compact single-page picker', async () => { + const user = userEvent.setup() + renderDialog() + const action = screen.getByRole('button', { name: 'Link to finding' }) + await waitFor(() => { expect(action).toBeEnabled() }) + expect(action.textContent).toBe('') + expect(action.querySelector('svg')).not.toBeNull() + await user.hover(action) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Link to finding') + await user.click(action) + await screen.findByRole('radio', { name: /Proof/ }) + expect(screen.getByRole('heading', { name: 'Attach to finding' })).toBeInTheDocument() + expect(screen.queryByText(/Conversation: related/)).not.toBeInTheDocument() + expect(screen.queryByText(/ - finding$/)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next findings' })).not.toBeInTheDocument() + await user.click(screen.getByRole('radio', { name: /Proof/ })) + expect(screen.queryByText('Selected: Proof')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Attach conversation' })).toHaveTextContent(/^Attach$/) +}) diff --git a/frontend/src/components/Chat/FindingEvidenceDialog.tsx b/frontend/src/components/Chat/FindingEvidenceDialog.tsx new file mode 100644 index 0000000000..43fd2ad809 --- /dev/null +++ b/frontend/src/components/Chat/FindingEvidenceDialog.tsx @@ -0,0 +1,325 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' + +import { + Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, + Badge, Field, Input, MessageBar, MessageBarBody, Radio, RadioGroup, Spinner, Text, Tooltip, +} from '@fluentui/react-components' +import { AddRegular, ChevronLeftRegular, ChevronRightRegular, LinkRegular } from '@fluentui/react-icons' +import { Link } from 'react-router' + +import { attacksApi, operationsApi } from '@/services/api' +import FindingDialog from '@/components/Operations/FindingDialog' +import { toApiError } from '@/services/errors' +import { findingSeverityLabel, findingSeverityColor } from '@/utils/findingSeverity' +import type { Finding, FindingCreate, FindingListItem, FindingListResponse, Operation } from '@/types' + +import { useFindingEvidenceDialogStyles } from './FindingEvidenceDialog.styles' + +const PAGE_SIZE = 20 + +interface FindingEvidenceDialogProps { + attackResultId: string + conversationId: string + disabled: boolean +} + +type AttributionState = + | { status: 'loading' } + | { status: 'eligible'; operation: Operation } + | { status: 'ineligible'; reason: string } + | { status: 'error'; reason: string } + +interface AttachmentSource { + operation: Operation + attackResultId: string + conversationId: string +} + +interface SavedAttachment extends AttachmentSource { + finding: Finding +} + +export default function FindingEvidenceDialog({ + attackResultId, conversationId, disabled, +}: FindingEvidenceDialogProps) { + const styles = useFindingEvidenceDialogStyles() + const [attribution, setAttribution] = useState({ status: 'loading' }) + const [lookupRevision, setLookupRevision] = useState(0) + const [open, setOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [creationSource, setCreationSource] = useState(null) + const [savedAttachment, setSavedAttachment] = useState(null) + const [creationAttachError, setCreationAttachError] = useState('') + const [title, setTitle] = useState('') + const [offset, setOffset] = useState(0) + const [page, setPage] = useState(null) + const [picked, setPicked] = useState<{ key: string; finding: FindingListItem } | null>(null) + const [listError, setListError] = useState('') + const [listRevision, setListRevision] = useState(0) + const [settledKey, setSettledKey] = useState('') + const [attachError, setAttachError] = useState('') + const [notice, setNotice] = useState('') + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const triggerRef = useRef(null) + const newFindingRef = useRef(null) + const wasOpen = useRef(false) + const wasCreating = useRef(false) + const viewRef = useRef({ attackResultId, conversationId, disabled, generation: 0 }) + const [attributionKey, setAttributionKey] = useState('') + const operation = attributionKey === attackResultId && attribution.status === 'eligible' ? attribution.operation : null + const requestKey = JSON.stringify([attackResultId, conversationId, operation?.id, title, offset, listRevision]) + // A selection only applies to the conversation and operation it was made for. + const selectionKey = JSON.stringify([attackResultId, conversationId, operation?.id]) + const selection = picked?.key === selectionKey ? picked.finding : null + const setSelection = (finding: FindingListItem | null): void => { + setPicked(finding ? { key: selectionKey, finding } : null) + } + + useLayoutEffect(() => { + viewRef.current = { attackResultId, conversationId, disabled, generation: viewRef.current.generation + 1 } + return () => { viewRef.current = { ...viewRef.current, disabled: true, generation: viewRef.current.generation + 1 } } + }, [attackResultId, conversationId, disabled]) + + useEffect(() => { + let ignore = false + const resolve = async (): Promise => { + try { + if (!attackResultId || !conversationId) return + const attack = await attacksApi.getAttack(attackResultId) + if (!attack?.operation) { + if (!ignore) { + setAttributionKey(attackResultId) + setAttribution({ status: 'ineligible', reason: 'The owning attack has no saved Operation attribution.' }) + } + return + } + const saved = await operationsApi.list() + const matching = saved.items.find(item => item.name === attack.operation) + if (!ignore) { + setAttributionKey(attackResultId) + setAttribution(matching + ? { status: 'eligible', operation: matching } + : { status: 'ineligible', reason: 'The owning attack attribution does not match a saved Operation exactly.' }) + } + } catch (cause: unknown) { + if (!ignore) { + setAttributionKey(attackResultId) + setAttribution({ status: 'error', reason: toApiError(cause).detail }) + } + } + } + void resolve() + return () => { ignore = true } + }, [attackResultId, conversationId, lookupRevision]) + + useEffect(() => { + if (!open || !operation) return + let ignore = false + operationsApi.searchFindings(operation.id, { limit: PAGE_SIZE, offset, title }) + .then(result => { + if (!ignore) { setPage(result); setListError(''); setSettledKey(requestKey) } + }) + .catch((cause: unknown) => { + if (!ignore) { setListError(toApiError(cause).detail); setSettledKey(requestKey) } + }) + return () => { ignore = true } + }, [open, operation, offset, title, requestKey]) + + useEffect(() => { + if (wasOpen.current && !open) triggerRef.current?.focus() + if (wasCreating.current && !creating) { + if (open) newFindingRef.current?.focus() + else triggerRef.current?.focus() + } + wasOpen.current = open + wasCreating.current = creating + }, [open, creating]) + + const retryEligible = savedAttachment !== null && !disabled + && savedAttachment.attackResultId === attackResultId && savedAttachment.conversationId === conversationId + const draftEligible = creationSource !== null && !disabled + && creationSource.attackResultId === attackResultId && creationSource.conversationId === conversationId + && creationSource.operation.id === operation?.id + + const attachSaved = async (saved: SavedAttachment, generation: number): Promise => { + try { + if (viewRef.current.generation !== generation || viewRef.current.disabled + || viewRef.current.attackResultId !== saved.attackResultId || viewRef.current.conversationId !== saved.conversationId) { + throw new Error('The viewed conversation changed. Return to the original conversation to retry attachment.') + } + await operationsApi.attachFindingEvidence(saved.operation.id, saved.finding.id, { + attack_result_id: saved.attackResultId, conversation_id: saved.conversationId, + }) + setSavedAttachment(null) + setCreationAttachError('') + setCreating(false) + setOpen(false) + setNotice(`Finding created and conversation attached: ${saved.finding.title}`) + } catch (cause: unknown) { + setCreationAttachError(toApiError(cause).detail) + } + } + + const createAndAttach = async (draft: FindingCreate): Promise => { + if (submittingRef.current) return + if (savedAttachment ? !retryEligible : !draftEligible || !creationSource) { + throw new Error('The viewed conversation changed. Return to the original conversation before saving.') + } + submittingRef.current = true + setSubmitting(true) + try { + const generation = viewRef.current.generation + if (savedAttachment) { + await attachSaved(savedAttachment, generation) + } else if (creationSource) { + const finding = await operationsApi.createFinding(creationSource.operation.id, draft) + const saved = { ...creationSource, finding } + setSavedAttachment(saved) + await attachSaved(saved, generation) + } + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + const recovery = savedAttachment && creationAttachError && ( +
+ + Finding created, but conversation attachment failed: {creationAttachError} + + {savedAttachment.finding.title} — Finding ID: {savedAttachment.finding.id} + Open saved finding Operation + {!retryEligible && Return to the original conversation to retry attachment.} +
+ ) + + const attach = async (): Promise => { + if (!operation || !selection || submittingRef.current || disabled) return + submittingRef.current = true + setSubmitting(true) + setAttachError('') + setNotice('') + try { + const response = await operationsApi.attachFindingEvidence(operation.id, selection.id, { + attack_result_id: attackResultId, conversation_id: conversationId, + }) + setNotice(response.created ? 'Attached' : 'Already attached') + setOpen(false) + } catch (cause: unknown) { + setAttachError(toApiError(cause).detail) + } finally { + submittingRef.current = false + setSubmitting(false) + } + } + + return ( + <> + {attackResultId && conversationId && + + } + {notice && {notice}} + {attackResultId && conversationId && attribution.status === 'loading' && Resolving Operation...} + {attackResultId && conversationId && attribution.status === 'ineligible' && {attribution.reason}} + {attackResultId && conversationId && attribution.status === 'error' && + Could not resolve Operation: {attribution.reason}{' '} + + } + {creating && + Operation: {savedAttachment?.operation.name ?? creationSource?.operation.name}. Save and attach the entire viewed conversation. + {!savedAttachment && !draftEligible && + The viewed conversation changed. Return to the original conversation before saving. + } + } + recovery={recovery || undefined} saveDisabled={savedAttachment ? !retryEligible : !draftEligible} + onSave={createAndAttach} onClose={() => { + setCreating(false) + if (savedAttachment) setOpen(false) + setListRevision(value => value + 1) + }} />} + { if (!submittingRef.current) setOpen(data.open) }}> + + Attach to finding + +
+ Operation: {operation?.name} + +
+ + { + setTitle(data.value); setOffset(0); setSelection(null); setNotice(''); setAttachError('') + }} /> + + {settledKey !== requestKey ? : listError ? ( + + Could not load findings: {listError}{' '} + + + ) : page?.items.length === 0 ? ( + {title ? 'No matching findings.' : 'No findings in this Operation yet. Choose New finding to create and attach.'}{' '} + Open Operation + + ) : ( + { + setSelection(page?.items.find(item => item.id === data.value) ?? null); setNotice(''); setAttachError('') + }}> + {page?.items.map(item => + {item.title} + + {findingSeverityLabel(item)} + + {item.harm_type && Harm-type: { + item.harm_type === 'Other' ? item.harm_type_other : item.harm_type + }} + {new Date(item.created_at).toLocaleString()} + + } />)} + + )} + {(offset > 0 || page?.has_more) &&
+
} + {attachError && Could not attach: {attachError}} +
+ + + + +
+
+ + ) +} diff --git a/frontend/src/components/Labels/LabelsBar.operations.test.tsx b/frontend/src/components/Labels/LabelsBar.operations.test.tsx new file mode 100644 index 0000000000..12a118e187 --- /dev/null +++ b/frontend/src/components/Labels/LabelsBar.operations.test.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import { labelsApi, operationsApi } from '@/services/api' +import type { Operation } from '@/types' +import LabelsBar from './LabelsBar' +import { DEFAULT_GLOBAL_LABELS } from './labelDefaults' + +jest.mock('@/services/api', () => ({ + labelsApi: { getLabels: jest.fn() }, + operationsApi: { list: jest.fn(), create: jest.fn() }, +})) + +const SAVED: Operation = { id: 'saved-1', name: 'Engagement / α% & #One', created_at: '2026-10-07T16:00:00Z' } +const onChange = jest.fn() + +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children} +} + +function Harness({ initial = { operator: 'alice' } }: { initial?: Record }) { + const [labels, setLabels] = useState(initial) + return { onChange(next); setLabels(next) }} /> +} + +function renderBar(initial?: Record): void { + render() +} + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(labelsApi.getLabels).mockResolvedValue({ source: 'attacks', labels: { operation: ['unsaved_history'] } }) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [SAVED] }) + jest.mocked(operationsApi.create).mockResolvedValue(SAVED) +}) + +describe('saved operation workflow', () => { + it('has no fresh operation placeholder', () => { + expect(DEFAULT_GLOBAL_LABELS).not.toHaveProperty('operation') + }) + + it('shows persistent named controls and pins creation above filtered choices', async () => { + const user = userEvent.setup() + renderBar() + expect(screen.getByRole('textbox', { name: 'Operator' })).toHaveValue('alice') + expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('') + expect(screen.queryByRole('button', { name: 'New operation' })).not.toBeInTheDocument() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await screen.findByRole('option', { name: SAVED.name }) + expect(screen.getAllByRole('option')[0]).toHaveTextContent('New operation…') + await user.type(screen.getByRole('combobox', { name: 'Operation' }), 'no match') + expect(screen.getAllByRole('option')[0]).toHaveTextContent('New operation…') + await user.click(screen.getByRole('option', { name: 'New operation…' })) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveFocus() + }) + + it('commits valid operator input, rejects invalid values and cancels with Escape', async () => { + const user = userEvent.setup() + renderBar() + const input = screen.getByRole('textbox', { name: 'Operator' }) + await user.clear(input) + await user.type(input, 'BOB{Enter}') + expect(onChange).toHaveBeenLastCalledWith({ operator: 'bob' }) + await user.clear(input) + await user.type(input, 'invalid value{Enter}') + expect(screen.getByText('Only lowercase letters, numbers, underscores')).toBeInTheDocument() + expect(onChange).toHaveBeenCalledTimes(1) + await user.keyboard('{Escape}') + expect(input).toHaveValue('bob') + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('keeps a signed-in operator read-only', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Signed-in operator' }) + expect(input).toHaveAttribute('readonly') + await user.type(input, 'bob') + expect(input).toHaveValue('alice') + expect(onChange).not.toHaveBeenCalled() + }) + + it('selects saved spelling exactly without offering observed or typed unsaved labels', async () => { + const user = userEvent.setup() + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + const input = screen.getByRole('combobox', { name: 'Operation' }) + await user.type(input, 'engagement') + await user.click(await screen.findByRole('option', { name: SAVED.name })) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice', operation: SAVED.name }) + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.type(screen.getByRole('combobox', { name: 'Operation' }), 'not saved') + expect(screen.queryByRole('option', { name: /create/i })).not.toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'unsaved_history' })).not.toBeInTheDocument() + await user.keyboard('{Enter}{Escape}') + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('does not offer a legacy selection as a saved choice and allows removing it', async () => { + const user = userEvent.setup() + renderBar({ operator: 'alice', operation: 'Legacy / LABEL' }) + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await screen.findByRole('option', { name: SAVED.name }) + expect(screen.queryByRole('option', { name: 'Legacy / LABEL' })).not.toBeInTheDocument() + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'Remove operation label' })) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice' }) + expect(screen.getByRole('combobox', { name: 'Operation' })).toBeInTheDocument() + }) + + it('creates inline, immediately selects the saved response, and restores focus', async () => { + const user = userEvent.setup() + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.click(screen.getByRole('option', { name: 'New operation…' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), ` ${SAVED.name} `) + await user.click(within(dialog).getByRole('button', { name: 'Create operation' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(operationsApi.create).toHaveBeenCalledWith({ name: ` ${SAVED.name} ` }) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice', operation: SAVED.name }) + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveFocus()) + }) + + it('recovers duplicate names by selecting the existing operation in place', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.create).mockRejectedValue({ + isAxiosError: true, response: { status: 409, data: { detail: { operation: SAVED } } }, + }) + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.click(screen.getByRole('option', { name: 'New operation…' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), SAVED.name.toUpperCase()) + await user.click(within(dialog).getByRole('button', { name: 'Create operation' })) + await user.click(await within(dialog).findByRole('button', { name: 'Use existing' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice', operation: SAVED.name }) + expect(operationsApi.create).toHaveBeenCalledTimes(1) + }) + + it('preserves input on save failure without reporting selection', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.create).mockRejectedValue(new Error('Storage unavailable')) + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.click(screen.getByRole('option', { name: 'New operation…' })) + const dialog = screen.getByRole('dialog') + await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), 'Keep me') + await user.click(within(dialog).getByRole('button', { name: 'Create operation' })) + expect(await within(dialog).findByText('Storage unavailable')).toBeInTheDocument() + expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveValue('Keep me') + expect(onChange).not.toHaveBeenCalled() + }) + + it.each(['Cancel', 'Escape'])('discards the inline draft with %s', async (action: string) => { + const user = userEvent.setup() + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.click(screen.getByRole('option', { name: 'New operation…' })) + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Discard me') + if (action === 'Cancel') await user.click(screen.getByRole('button', { name: 'Cancel' })) + else await user.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.click(screen.getByRole('option', { name: 'New operation…' })) + expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('') + expect(onChange).not.toHaveBeenCalled() + }) + + it('reports picker failure and can retry without accepting typed names', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('List unavailable')) + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + expect(await screen.findByText(/list unavailable/i)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry operations' })) + await user.click(await screen.findByRole('option', { name: SAVED.name })) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice', operation: SAVED.name }) + }) + + it('ignores a late list response from a dismissed edit', async () => { + const user = userEvent.setup() + let finish: (value: { items: Operation[] }) => void = () => { throw new Error('Request not started') } + jest.mocked(operationsApi.list).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + renderBar() + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await user.keyboard('{Escape}') + await user.click(screen.getByRole('combobox', { name: 'Operation' })) + await screen.findByRole('option', { name: SAVED.name }) + await act(async () => { finish({ items: [{ ...SAVED, name: 'Stale operation' }] }) }) + expect(screen.queryByRole('option', { name: 'Stale operation' })).not.toBeInTheDocument() + await user.click(screen.getByRole('option', { name: SAVED.name })) + expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice', operation: SAVED.name }) + }) +}) diff --git a/frontend/src/components/Labels/LabelsBar.styles.ts b/frontend/src/components/Labels/LabelsBar.styles.ts index b714be527a..4a1780ecb0 100644 --- a/frontend/src/components/Labels/LabelsBar.styles.ts +++ b/frontend/src/components/Labels/LabelsBar.styles.ts @@ -1,5 +1,5 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '../../styles/touchTargets' +import { mobileTouchTarget, mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' export const useLabelsBarStyles = makeStyles({ root: { @@ -27,12 +27,16 @@ export const useLabelsBarStyles = makeStyles({ display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalXS, - flexWrap: 'nowrap', + flexWrap: 'wrap', // Metadata has no popover fallback, so keep it reachable on narrow bars. overflowX: 'auto', flex: '1 1 0', minWidth: 0, }, + metadata: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalM, minWidth: 0 }, + metadataField: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalXS, minWidth: 0 }, + operatorEditor: { position: 'relative', display: 'flex', flexDirection: 'column' }, + operatorInput: { width: '100px', ...mobileTouchTargetHeight }, measureRow: { position: 'absolute', visibility: 'hidden', @@ -82,7 +86,10 @@ export const useLabelsBarStyles = makeStyles({ width: '16px', height: '16px', padding: 0, + ...mobileTouchTarget, }, + actionButton: { flexShrink: 0, ...mobileTouchTargetHeight }, + operationEditor: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalXS, minWidth: 0 }, popover: { [NARROW_VIEWPORT_QUERY]: { boxSizing: 'border-box', @@ -147,7 +154,7 @@ export const useLabelsBarStyles = makeStyles({ // clips what overflows. Let it shrink rather than lose its chevron: Fluent // puts an intrinsic min-width on both the root and the inner input. operationPicker: { - width: '180px', + width: '140px', minWidth: 0, maxWidth: '100%', '& input': { diff --git a/frontend/src/components/Labels/LabelsBar.test.tsx b/frontend/src/components/Labels/LabelsBar.test.tsx index 524d39c4c2..23fee20b87 100644 --- a/frontend/src/components/Labels/LabelsBar.test.tsx +++ b/frontend/src/components/Labels/LabelsBar.test.tsx @@ -3,13 +3,17 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { FluentProvider, webLightTheme } from '@fluentui/react-components' import LabelsBar from './LabelsBar' -import { DEFAULT_GLOBAL_LABELS } from './labelDefaults' -import { labelsApi } from '../../services/api' +import { DEFAULT_GLOBAL_LABELS as FRESH_GLOBAL_LABELS } from './labelDefaults' +import { labelsApi, operationsApi } from '@/services/api' + +// These regressions also exercise preferences saved before operations were persistent records. +const DEFAULT_GLOBAL_LABELS = { ...FRESH_GLOBAL_LABELS, operation: 'op_trash_panda' } jest.mock('../../services/api', () => ({ labelsApi: { getLabels: jest.fn(), }, + operationsApi: { list: jest.fn(), create: jest.fn() }, })) const mockedLabelsApi = labelsApi as jest.Mocked @@ -22,9 +26,10 @@ describe('LabelsBar', () => { beforeEach(() => { jest.clearAllMocks() mockedLabelsApi.getLabels.mockImplementation(() => new Promise(() => {})) + jest.mocked(operationsApi.list).mockImplementation(() => new Promise(() => {})) }) - it('should render default labels', () => { + it('should render legacy default labels', () => { const onChange = jest.fn() render( @@ -36,8 +41,8 @@ describe('LabelsBar', () => { // also has an aria-hidden "measure" row with mirrored chips used // purely to compute available width — query by data-testid so we // don't accidentally match the hidden mirror. - expect(screen.getByTestId('label-operator')).toHaveTextContent('roakey') - expect(screen.getByTestId('label-operation')).toHaveTextContent('op_trash_panda') + expect(screen.getByTestId('edit-label-operator')).toHaveValue('roakey') + expect(screen.getByTestId('edit-label-operation')).toHaveValue('op_trash_panda') }) it('should show warning icon for dummy values', () => { @@ -58,15 +63,15 @@ describe('LabelsBar', () => { , ) - const operator = screen.getByRole('button', { name: 'Signed-in operator: alice' }) - expect(operator).toHaveAttribute('aria-disabled', 'true') + const operator = screen.getByRole('textbox', { name: 'Signed-in operator' }) + expect(operator).toHaveAttribute('readonly') await user.click(operator) - expect(screen.queryByTestId('edit-label-operator')).not.toBeInTheDocument() + expect(operator).toHaveValue('alice') await user.click(screen.getByTestId('labels-icon-btn')) expect(await screen.findByRole('heading', { name: 'Default Labels' })).toBeInTheDocument() expect(screen.queryByTestId('popover-metadata-operator')).not.toBeInTheDocument() expect(screen.queryByTestId('popover-label-operator')).not.toBeInTheDocument() - expect(screen.queryByTestId('edit-label-operator')).not.toBeInTheDocument() + expect(screen.getByTestId('edit-label-operator')).toHaveAttribute('readonly') expect(onChange).not.toHaveBeenCalled() }) @@ -130,16 +135,15 @@ describe('LabelsBar', () => { expect(warning).toHaveFocus() }) - it('should not allow removing required labels (operator, operation)', () => { + it('should allow removing the operation but not the operator', () => { render( ) - // operator and operation should not have remove buttons expect(screen.queryByTestId('remove-label-operator')).not.toBeInTheDocument() - expect(screen.queryByTestId('remove-label-operation')).not.toBeInTheDocument() + expect(screen.getByTestId('remove-label-operation')).toBeInTheDocument() }) it('should allow removing custom labels', () => { @@ -169,7 +173,7 @@ describe('LabelsBar', () => { ) - expect(screen.getByTestId('label-operation')).toHaveAttribute('aria-describedby') + expect(screen.getByRole('combobox', { name: 'Operation' })).toBeInTheDocument() }) it('should keep the remove button out of the edit control', async () => { @@ -189,7 +193,7 @@ describe('LabelsBar', () => { expect(edit).not.toContainElement(remove) expect(remove).toHaveAccessibleName('Remove team label') // Required labels have nothing to nest in the first place. - expect(screen.getByTestId('label-operator')).toHaveAttribute('role', 'button') + expect(screen.getByRole('textbox', { name: 'Operator' })).toBeInTheDocument() }) it('should start an edit when the chip is clicked beside the edit control', async () => { @@ -314,7 +318,7 @@ describe('LabelsBar', () => { ) // Click on operator label to edit - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -353,7 +357,7 @@ describe('LabelsBar', () => { await waitFor(() => { expect(mockedLabelsApi.getLabels).toHaveBeenCalled() }) - expect(screen.getByTestId('label-operator')).toBeInTheDocument() + expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() }) it('should reject empty key when adding a label', async () => { @@ -407,7 +411,7 @@ describe('LabelsBar', () => { ) // Click on operator label to start editing - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -435,19 +439,20 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: 'alice' } }) // Leaving the operator schedules its save; the click starts the next edit. fireEvent.blur(operatorInput) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.focus(screen.getByTestId('edit-label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByTestId('edit-label-operation') await act(async () => { await new Promise(r => setTimeout(r, 400)) }) expect(screen.getByTestId('edit-label-operation')).toBeInTheDocument() - expect(screen.queryByTestId('edit-label-operator')).not.toBeInTheDocument() + expect(screen.getByTestId('edit-label-operator')).toHaveValue(DEFAULT_GLOBAL_LABELS.operator) // The operator edit still went in; only its clean-up was skipped. expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_GLOBAL_LABELS, operator: 'alice' }) }) @@ -461,10 +466,10 @@ describe('LabelsBar', () => { ) // Leaving the operation picker for the operator, the other way round. - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const operationInput = await screen.findByTestId('edit-label-operation') fireEvent.blur(operationInput) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') await act(async () => { await new Promise(r => setTimeout(r, 400)) }) @@ -484,11 +489,11 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) fireEvent.blur(await screen.findByTestId('edit-label-operator')) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByTestId('edit-label-operation') - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await screen.findByTestId('edit-label-operator') await act(async () => { await new Promise(r => setTimeout(r, 400)) }) @@ -505,11 +510,12 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: '' } }) fireEvent.blur(operatorInput) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.focus(screen.getByTestId('edit-label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByTestId('edit-label-operation') await act(async () => { await new Promise(r => setTimeout(r, 400)) }) @@ -529,7 +535,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: 'dana' } }) fireEvent.blur(operatorInput) @@ -568,7 +574,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: 'al' } }) @@ -608,7 +614,7 @@ describe('LabelsBar', () => { // Wait for the suggestions once, then run the sequence without awaiting // anything: both edits have to finish inside the same save delay. - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: 'al' } }) const alice = await screen.findByText('alice') @@ -639,7 +645,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) const operatorInput = await screen.findByTestId('edit-label-operator') fireEvent.change(operatorInput, { target: { value: 'dana' } }) fireEvent.blur(operatorInput) @@ -690,7 +696,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -703,7 +709,7 @@ describe('LabelsBar', () => { expect(onChange).not.toHaveBeenCalled() // Edit mode should be closed - the original label should reappear await waitFor(() => { - expect(screen.getByTestId('label-operator')).toBeInTheDocument() + expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() }) }) @@ -715,7 +721,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -867,7 +873,7 @@ describe('LabelsBar', () => { }) // Click on operator to edit - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -903,7 +909,7 @@ describe('LabelsBar', () => { expect(mockedLabelsApi.getLabels).toHaveBeenCalled() }) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) await waitFor(() => { expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() @@ -933,9 +939,8 @@ describe('LabelsBar', () => { ) - // operator and operation should not have remove buttons (already tested) expect(screen.queryByTestId('remove-label-operator')).not.toBeInTheDocument() - expect(screen.queryByTestId('remove-label-operation')).not.toBeInTheDocument() + expect(screen.getByTestId('remove-label-operation')).toBeInTheDocument() // team should have a remove button expect(screen.getByTestId('remove-label-team')).toBeInTheDocument() @@ -1033,6 +1038,10 @@ describe('LabelsBar', () => { const root = container.querySelector('[data-testid="labels-bar"]') as HTMLElement | null if (!root) throw new Error('labels-bar not found') Object.defineProperty(root, 'clientWidth', { configurable: true, value: 250 }) + const metadata = screen.getByRole('combobox', { name: 'Operation' }).closest('[data-testid="labels-bar"]') + ?.querySelector('[data-testid="edit-label-operator"]')?.parentElement?.parentElement?.parentElement?.parentElement + if (!metadata) throw new Error('metadata controls not found') + Object.defineProperty(metadata, 'offsetWidth', { configurable: true, value: 240 }) // Only one 100 px chip fits after reserving room for the icon button. const measure = root.querySelector('[aria-hidden="true"]') as HTMLElement | null if (measure) { @@ -1060,8 +1069,8 @@ describe('LabelsBar', () => { }) // Metadata remains in the scrollable bar; custom labels stay in the popover. - expect(screen.getByTestId('label-operator')).toBeInTheDocument() - expect(screen.getByTestId('label-operation')).toBeInTheDocument() + expect(screen.getByTestId('edit-label-operator')).toBeInTheDocument() + expect(screen.getByTestId('edit-label-operation')).toBeInTheDocument() expect(screen.queryByTestId('label-team')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('labels-icon-btn')) await waitFor(() => { @@ -1072,11 +1081,11 @@ describe('LabelsBar', () => { expect(screen.queryByTestId('popover-metadata-operator')).not.toBeInTheDocument() expect(screen.queryByTestId('popover-metadata-operation')).not.toBeInTheDocument() fireEvent.click(screen.getByTestId('labels-icon-btn')) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) expect(await screen.findByTestId('edit-label-operator')).toBeInTheDocument() fireEvent.keyDown(screen.getByTestId('edit-label-operator'), { key: 'Enter' }) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect(await screen.findByTestId('edit-label-operation')).toBeInTheDocument() }) @@ -1084,6 +1093,9 @@ describe('LabelsBar', () => { const OPERATIONS = ['op_2026_07_grok_45', 'op_2026_08_probe', 'validate-button-test'] function renderWithOperations(onChange: jest.Mock, operations: string[] = OPERATIONS) { + jest.mocked(operationsApi.list).mockResolvedValue({ + items: operations.map((name, index) => ({ id: `operation-${index}`, name, created_at: '2026-10-07T16:00:00Z' })), + }) mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', operators: ['alice'], @@ -1102,12 +1114,12 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'op_2026_07_grok_45' })).toBeInTheDocument() const input = screen.getByTestId('edit-label-operation') as HTMLInputElement - expect(input.placeholder).toBe(DEFAULT_GLOBAL_LABELS.operation) + expect(input.placeholder).toBe('Search operations') expect(input.value).toBe('') }) @@ -1116,7 +1128,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) fireEvent.click(await screen.findByRole('option', { name: 'op_2026_08_probe' })) expect(onChange).toHaveBeenCalledWith({ @@ -1130,7 +1142,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) fireEvent.click(await screen.findByRole('option', { name: 'validate-button-test' })) expect(onChange).toHaveBeenCalledWith({ @@ -1144,7 +1156,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByRole('option', { name: 'op_2026_08_probe' }) fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'grok' } }) @@ -1152,52 +1164,45 @@ describe('LabelsBar', () => { expect(screen.queryByRole('option', { name: 'op_2026_08_probe' })).not.toBeInTheDocument() }) - it('should create a new operation from typed text', async () => { + it('should not create a new operation from typed text', async () => { const onChange = jest.fn() renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByRole('option', { name: 'op_2026_08_probe' }) fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'op_2026_09_new' } }) - fireEvent.click(await screen.findByRole('option', { name: 'Create "op_2026_09_new"' })) - - expect(onChange).toHaveBeenCalledWith({ - ...DEFAULT_GLOBAL_LABELS, - operation: 'op_2026_09_new', - }) + expect(screen.queryByRole('option', { name: 'Create "op_2026_09_new"' })).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() }) - it('should refuse to create a new operation that breaks the value rules', async () => { + it('should treat arbitrary punctuation as search, not creation', async () => { const onChange = jest.fn() renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByRole('option', { name: 'op_2026_08_probe' }) fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'bad name!' } }) - // The rules are stated while typing instead of offering a create that fails. - expect( - await screen.findByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) - ).toBeInTheDocument() + expect(await screen.findByRole('option', { name: 'No matching saved operations.' })).toBeInTheDocument() expect(screen.queryByRole('option', { name: 'Create "bad name!"' })).not.toBeInTheDocument() expect(onChange).not.toHaveBeenCalled() }) - it('should drop the rules note once the typed name becomes valid', async () => { + it('should restore saved matches when the search changes', async () => { const onChange = jest.fn() renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') fireEvent.change(input, { target: { value: 'bad name!' } }) - await screen.findByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) + await screen.findByRole('option', { name: 'No matching saved operations.' }) - fireEvent.change(input, { target: { value: 'op_2026_09_ok' } }) + fireEvent.change(input, { target: { value: 'probe' } }) - expect(await screen.findByRole('option', { name: 'Create "op_2026_09_ok"' })).toBeInTheDocument() + expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() expect( screen.queryByRole('option', { name: 'Only lowercase letters, numbers, underscores' }) ).not.toBeInTheDocument() @@ -1208,11 +1213,13 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') // Narrow to a single option so the active option is unambiguous. fireEvent.change(input, { target: { value: 'grok' } }) await screen.findByRole('option', { name: 'op_2026_07_grok_45' }) + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'ArrowDown' }) fireEvent.keyDown(input, { key: 'Enter' }) expect(onChange).toHaveBeenCalledWith({ @@ -1226,28 +1233,32 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') fireEvent.keyDown(input, { key: 'Escape' }) await waitFor(() => { - expect(screen.queryByTestId('edit-label-operation')).not.toBeInTheDocument() + expect(input).toHaveAttribute('aria-expanded', 'false') + expect(input).toHaveValue(DEFAULT_GLOBAL_LABELS.operation) }) expect(onChange).not.toHaveBeenCalled() }) - it('should offer creation when no operations exist yet', async () => { + it('should direct users to explicit creation when no saved operations exist', async () => { const onChange = jest.fn() renderWithOperations(onChange, []) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) - expect(await screen.findByRole('option', { name: /type a name to create one/i })).toBeInTheDocument() + fireEvent.click(screen.getByTestId('edit-label-operation')) + await waitFor(() => { + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('option', { name: 'New operation…' })).toBeInTheDocument() + }) fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: 'op_first' } }) - fireEvent.click(await screen.findByRole('option', { name: 'Create "op_first"' })) - - expect(onChange).toHaveBeenCalledWith({ ...DEFAULT_GLOBAL_LABELS, operation: 'op_first' }) + expect(screen.queryByRole('option', { name: 'Create "op_first"' })).not.toBeInTheDocument() + expect(screen.getAllByRole('option')[0]).toHaveTextContent('New operation') + expect(onChange).not.toHaveBeenCalled() }) it('should show a loading option while operations are still being fetched', async () => { @@ -1259,7 +1270,7 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect(await screen.findByRole('option', { name: /loading operations/i })).toBeInTheDocument() }) @@ -1270,13 +1281,13 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - await user.click(screen.getByTestId('label-operation')) + await user.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByRole('combobox', { name: 'Operation' }) await waitFor(() => expect(input).toHaveFocus()) await user.click(document.body) await waitFor(() => { - expect(input).not.toBeInTheDocument() + expect(input).toHaveAttribute('aria-expanded', 'false') }) expect(onChange).not.toHaveBeenCalled() }) @@ -1287,13 +1298,13 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - // The chip must be a real, focusable control before it can be activated. - const chip = screen.getByTestId('label-operation') - expect(chip).toHaveAttribute('role', 'button') - expect(chip).toHaveAttribute('aria-label', expect.stringContaining(DEFAULT_GLOBAL_LABELS.operation)) + // The permanent combobox remains focusable while closed. + const chip = screen.getByTestId('edit-label-operation') + expect(chip).toHaveAttribute('role', 'combobox') + expect(chip).toHaveValue(DEFAULT_GLOBAL_LABELS.operation) chip.focus() expect(chip).toHaveFocus() - await user.keyboard('{Enter}') + await user.keyboard('{ArrowDown}') const input = await screen.findByTestId('edit-label-operation') expect(await screen.findByRole('option', { name: 'op_2026_08_probe' })).toBeInTheDocument() @@ -1306,7 +1317,8 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.keyDown(screen.getByTestId('label-operation'), { key: 'Enter' }) + fireEvent.focus(screen.getByTestId('edit-label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByTestId('edit-label-operation') await user.tab() @@ -1327,8 +1339,9 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.keyDown(screen.getByTestId('label-operation'), { key: 'Enter' }) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') + input.focus() await waitFor(() => expect(input).toHaveFocus()) const nextButton = screen.getByRole('button', { name: 'after' }) @@ -1337,7 +1350,7 @@ describe('LabelsBar', () => { fireEvent.keyDown(input, { key: 'Tab' }) nextButton.focus() - await waitFor(() => expect(input).not.toBeInTheDocument()) + await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'false')) expect(onChange).not.toHaveBeenCalled() expect(nextButton).toHaveFocus() }) @@ -1347,7 +1360,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange, ['op_Legacy_Run']) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') // A partial match still finds the differently-cased operation. @@ -1399,7 +1412,7 @@ describe('LabelsBar', () => { it('should say so when the operations could not be loaded', async () => { const onChange = jest.fn() - mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom')) render( @@ -1407,57 +1420,52 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect( - await screen.findByRole('option', { name: /could not load existing operations/i }) + await screen.findByRole('option', { name: /could not load saved operations/i }) ).toBeInTheDocument() expect(screen.queryByRole('option', { name: /no operations yet/i })).not.toBeInTheDocument() }) - it('should create a typed name with the keyboard', async () => { + it('should not select an unsaved typed name with the keyboard', async () => { const onChange = jest.fn() renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') fireEvent.change(input, { target: { value: 'op_2026_09_typed' } }) - await screen.findByRole('option', { name: 'Create "op_2026_09_typed"' }) + await screen.findByRole('option', { name: 'No matching saved operations.' }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(onChange).toHaveBeenCalledWith({ - ...DEFAULT_GLOBAL_LABELS, - operation: 'op_2026_09_typed', - }) + expect(onChange).not.toHaveBeenCalled() }) - it('should keep a newly created operation in the list', async () => { + it('should refresh saved operations when reopening the picker', async () => { const onChange = jest.fn() renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) - fireEvent.change(await screen.findByTestId('edit-label-operation'), { - target: { value: 'op_2026_09_fresh' }, + fireEvent.click(screen.getByTestId('edit-label-operation')) + fireEvent.keyDown(await screen.findByTestId('edit-label-operation'), { key: 'Escape' }) + jest.mocked(operationsApi.list).mockResolvedValue({ + items: [{ id: 'fresh', name: 'op_2026_09_fresh', created_at: '2026-10-07T16:00:00Z' }], }) - fireEvent.click(await screen.findByRole('option', { name: 'Create "op_2026_09_fresh"' })) - // Reopen: the name it just created has to still be selectable. - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect(await screen.findByRole('option', { name: 'op_2026_09_fresh' })).toBeInTheDocument() expect(screen.queryByRole('option', { name: 'Create "op_2026_09_fresh"' })).not.toBeInTheDocument() }) - it('should list the operation in use even when the saved list has not caught up', async () => { - // The labels bar in the ribbon and the one on Home each fetch their own - // list, so a name chosen in the other one is not in this response yet. + it('should not add the operation in use to the saved choices', async () => { const onChange = jest.fn() mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: { operation: OPERATIONS, operator: ['alice'] }, }) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [] }) render( { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) - expect(await screen.findByRole('option', { name: 'op_chosen_elsewhere' })).toBeInTheDocument() + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1)) + expect(screen.getByRole('option', { name: 'New operation…' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_chosen_elsewhere' })).not.toBeInTheDocument() // Typing it must not offer to create the name that is already set. fireEvent.change(screen.getByTestId('edit-label-operation'), { @@ -1481,9 +1491,7 @@ describe('LabelsBar', () => { ).not.toBeInTheDocument() }) - it('should let you re-select the operation in use even if it breaks the naming rules', async () => { - // A legacy name can be in use without being in the labels API — from a - // config file, or a session where nothing was stored under it yet. + it('should allow removing a legacy selected operation', async () => { const onChange = jest.fn() mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', @@ -1499,21 +1507,18 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) - fireEvent.click(await screen.findByRole('option', { name: 'legacy-op-name.2024' })) - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ operation: 'legacy-op-name.2024' }) - ) + fireEvent.click(screen.getByTestId('remove-label-operation')) + expect(onChange).toHaveBeenCalledWith({ operator: FRESH_GLOBAL_LABELS.operator }) expect(screen.queryByText(/Only lowercase letters/)).not.toBeInTheDocument() }) - it('should not say there are no operations while showing the one in use', async () => { + it('should offer only New operation when the saved list is empty and a legacy operation is selected', async () => { const onChange = jest.fn() mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: { operation: [], operator: ['alice'] }, }) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [] }) render( { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) - expect(await screen.findByRole('option', { name: 'op_only_one' })).toBeInTheDocument() - expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(1)) + expect(screen.getByRole('option', { name: 'New operation…' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_only_one' })).not.toBeInTheDocument() }) - it('should keep saying the operations could not be loaded after one is created', async () => { - // A name created while the request was still in flight is a local - // value, not proof that the list arrived. + it('should not accept a typed name while the saved list is loading or failed', async () => { const onChange = jest.fn() let rejectLabels: (reason: Error) => void = () => {} - mockedLabelsApi.getLabels.mockReturnValue( + jest.mocked(operationsApi.list).mockReturnValueOnce( new Promise((_resolve, reject) => { rejectLabels = reject }) ) render( @@ -1544,28 +1548,25 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) fireEvent.change(await screen.findByTestId('edit-label-operation'), { target: { value: 'op_made_during_load' }, }) - fireEvent.click(await screen.findByRole('option', { name: 'Create "op_made_during_load"' })) + expect(onChange).not.toHaveBeenCalled() await act(async () => { rejectLabels(new Error('boom')) }) - fireEvent.click(screen.getByTestId('label-operation')) - expect( - await screen.findByRole('option', { name: /Could not load existing operations/ }) + await screen.findByRole('option', { name: /Could not load saved operations/ }) ).toBeInTheDocument() - expect(screen.getByRole('option', { name: 'op_made_during_load' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_made_during_load' })).not.toBeInTheDocument() }) it('should still say the operations could not be loaded when one is already set', async () => { - // The value in use is listed, but that must not read as a loaded list. const onChange = jest.fn() - mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom')) render( { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) expect( - await screen.findByRole('option', { name: /Could not load existing operations/ }) + await screen.findByRole('option', { name: /Could not load saved operations/ }) ).toBeInTheDocument() - expect(screen.getByRole('option', { name: 'op_already_set' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_already_set' })).not.toBeInTheDocument() }) - it('should keep the operation in use on the list when the list is capped', async () => { - // The value in use is put at the front of whatever the API returned, so - // a cap applied to the end of the list is exactly what would drop it. + it('should not inject a legacy operation into a capped saved list', async () => { const onChange = jest.fn() const many = Array.from({ length: 250 }, (_, i) => `op_2026_08_run_${String(i).padStart(4, '0')}`) mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: { operation: many, operator: ['alice'] }, }) + jest.mocked(operationsApi.list).mockResolvedValue({ + items: many.map((name, index) => ({ id: `op-${index}`, name, created_at: '2026-10-07T16:00:00Z' })), + }) render( { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) - const inUse = await screen.findByRole('option', { name: 'op_chosen_elsewhere' }) - expect(inUse).toBeInTheDocument() - - // And it is still selectable, not just present. - fireEvent.click(inUse) - expect(onChange).toHaveBeenCalledWith({ - ...DEFAULT_GLOBAL_LABELS, - operation: 'op_chosen_elsewhere', - }) + await screen.findByRole('option', { name: /type to narrow/i }) + expect(screen.queryByRole('option', { name: 'op_chosen_elsewhere' })).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() }) it('should keep the operation in use on a capped list that already contains it', async () => { @@ -1625,6 +1621,9 @@ describe('LabelsBar', () => { source: 'attacks', labels: { operation: many, operator: ['alice'] }, }) + jest.mocked(operationsApi.list).mockResolvedValue({ + items: many.map((name, index) => ({ id: `op-${index}`, name, created_at: '2026-10-07T16:00:00Z' })), + }) render( { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) // Listed once, not twice, even though it is also in the saved list. expect(await screen.findAllByRole('option', { name: 'op_2026_08_run_0240' })).toHaveLength(1) @@ -1650,7 +1649,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange, [...decoys, 'run_042'].sort()) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) fireEvent.change(await screen.findByTestId('edit-label-operation'), { target: { value: 'run_042' }, }) @@ -1673,10 +1672,10 @@ describe('LabelsBar', () => { renderWithOperations(onChange, many) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) await screen.findByRole('option', { name: 'op_2026_08_run_0000' }) - expect(screen.getAllByRole('option')).toHaveLength(201) + expect(screen.getAllByRole('option')).toHaveLength(202) expect(screen.getByText('Showing 200 of 250 — type to narrow')).toBeInTheDocument() expect(screen.queryByRole('option', { name: 'op_2026_08_run_0249' })).not.toBeInTheDocument() @@ -1694,7 +1693,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange, many) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const note = await screen.findByRole('option', { name: /type to narrow/ }) expect(note).toHaveAttribute('aria-disabled', 'true') @@ -1705,7 +1704,7 @@ describe('LabelsBar', () => { it('should keep saying the operations could not be loaded while a name is typed', async () => { // The note answers "why is this list empty"; typing does not answer it. const onChange = jest.fn() - mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom')) render( @@ -1713,19 +1712,19 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const input = await screen.findByTestId('edit-label-operation') fireEvent.change(input, { target: { value: 'op_2026_09_typed' } }) - expect(await screen.findByRole('option', { name: 'Create "op_2026_09_typed"' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Create "op_2026_09_typed"' })).not.toBeInTheDocument() expect( - screen.getByRole('option', { name: /Could not load existing operations/ }) + await screen.findByRole('option', { name: /Could not load saved operations/ }) ).toBeInTheDocument() fireEvent.change(input, { target: { value: 'op bad' } }) - expect(await screen.findByText(/Only lowercase letters/)).toBeInTheDocument() + expect(screen.queryByText(/Only lowercase letters/)).not.toBeInTheDocument() expect( - screen.getByRole('option', { name: /Could not load existing operations/ }) + screen.getByRole('option', { name: /Could not load saved operations/ }) ).toBeInTheDocument() }) @@ -1733,7 +1732,7 @@ describe('LabelsBar', () => { // The notes share the option list with real values, so they have to be // unselectable or one of them becomes the operation. const onChange = jest.fn() - mockedLabelsApi.getLabels.mockRejectedValue(new Error('boom')) + jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom')) render( @@ -1741,29 +1740,27 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) const failed = await screen.findByRole('option', { - name: /Could not load existing operations/, + name: /Could not load saved operations/, }) expect(failed).toHaveAttribute('aria-disabled', 'true') fireEvent.change(await screen.findByTestId('edit-label-operation'), { target: { value: 'op bad' }, }) - const invalid = await screen.findByRole('option', { name: /Only lowercase letters/ }) - expect(invalid).toHaveAttribute('aria-disabled', 'true') - fireEvent.click(failed) - fireEvent.click(invalid) expect(onChange).not.toHaveBeenCalled() }) - it('should not say there are no operations while offering to create one', async () => { + it('should offer only New operation for an empty saved list, including while typing', async () => { + const user = userEvent.setup() const onChange = jest.fn() mockedLabelsApi.getLabels.mockResolvedValue({ source: 'attacks', labels: { operation: [], operator: ['alice'] }, }) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [] }) render( @@ -1771,24 +1768,29 @@ describe('LabelsBar', () => { ) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operation')) - const input = await screen.findByTestId('edit-label-operation') - expect(await screen.findByText(/No operations yet/)).toBeInTheDocument() + const input = screen.getByRole('combobox', { name: 'Operation' }) + await user.click(input) + await waitFor(() => { + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('option', { name: 'New operation…' })).toBeInTheDocument() + }) - fireEvent.change(input, { target: { value: 'op_2026_09_first' } }) - expect(await screen.findByRole('option', { name: 'Create "op_2026_09_first"' })).toBeInTheDocument() - expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + await user.type(input, 'op_2026_09_first') + expect(screen.queryByRole('option', { name: 'Create "op_2026_09_first"' })).not.toBeInTheDocument() + expect(screen.getAllByRole('option')).toHaveLength(1) - // Same while the typed name is one that cannot be created. - fireEvent.change(input, { target: { value: 'op bad' } }) - expect(await screen.findByText(/Only lowercase letters/)).toBeInTheDocument() - expect(screen.queryByText(/No operations yet/)).not.toBeInTheDocument() + await user.clear(input) + await user.type(input, 'op bad') + expect(screen.queryByText(/Only lowercase letters/)).not.toBeInTheDocument() + expect(screen.getAllByRole('option')).toHaveLength(1) + expect(screen.getByRole('option', { name: 'New operation…' })).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() }) - it('should keep an operation created while the list was still loading', async () => { + it('should not select a typed name when a delayed saved list arrives', async () => { const onChange = jest.fn() - let resolveLabels: (value: { source: string; labels: Record }) => void = () => {} - mockedLabelsApi.getLabels.mockReturnValue( + let resolveLabels: (value: { items: Array<{ id: string; name: string; created_at: string }> }) => void = () => {} + jest.mocked(operationsApi.list).mockReturnValueOnce( new Promise(resolve => { resolveLabels = resolve }) ) render( @@ -1797,21 +1799,18 @@ describe('LabelsBar', () => { ) - fireEvent.click(screen.getByTestId('label-operation')) + fireEvent.click(screen.getByTestId('edit-label-operation')) fireEvent.change(await screen.findByTestId('edit-label-operation'), { target: { value: 'op_made_while_loading' }, }) - fireEvent.click(await screen.findByRole('option', { name: 'Create "op_made_while_loading"' })) - // The response was in flight and cannot know about the name just created. await act(async () => { - resolveLabels({ source: 'attacks', labels: { operation: ['op_from_server'] } }) + resolveLabels({ items: [{ id: 'server', name: 'op_from_server', created_at: '2026-10-07T16:00:00Z' }] }) }) - - fireEvent.click(screen.getByTestId('label-operation')) - - expect(await screen.findByRole('option', { name: 'op_made_while_loading' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'op_made_while_loading' })).not.toBeInTheDocument() + fireEvent.change(screen.getByTestId('edit-label-operation'), { target: { value: '' } }) expect(screen.getByRole('option', { name: 'op_from_server' })).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() }) it('should keep the plain input for labels other than operation', async () => { @@ -1819,7 +1818,7 @@ describe('LabelsBar', () => { renderWithOperations(onChange) await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled()) - fireEvent.click(screen.getByTestId('label-operator')) + fireEvent.click(screen.getByTestId('edit-label-operator')) expect(await screen.findByTestId('edit-label-operator')).toBeInTheDocument() expect(screen.queryByRole('option')).not.toBeInTheDocument() diff --git a/frontend/src/components/Labels/LabelsBar.tsx b/frontend/src/components/Labels/LabelsBar.tsx index c7df0e5297..a95dd12407 100644 --- a/frontend/src/components/Labels/LabelsBar.tsx +++ b/frontend/src/components/Labels/LabelsBar.tsx @@ -4,8 +4,6 @@ import { Button, Input, Badge, - Combobox, - Option, Tooltip, Popover, PopoverTrigger, @@ -16,7 +14,8 @@ import { WarningRegular, TagRegular, } from '@fluentui/react-icons' -import { labelsApi } from '../../services/api' +import { labelsApi } from '@/services/api' +import OperationPicker from './OperationPicker' import { useLabelsBarStyles } from './LabelsBar.styles' @@ -35,143 +34,12 @@ const DUMMY_VALUES: Record = { const METADATA_KEYS = new Set(['operator', 'operation']) -// Fluent's listbox renders every option as a real component, so a long list -// stalls opening and typing. Past this many, you narrow the list by typing. -const MAX_LISTED = 200 - interface LabelsBarProps { labels: Record onLabelsChange: (labels: Record) => void operatorReadOnly?: boolean } -interface OperationPickerProps { - currentValue: string - options: string[] - isLoading: boolean - loadFailed: boolean - onSelect: (operation: string) => void - onSearchChange: () => void - onDismiss: () => void - inputRef: React.Ref - className?: string - listboxClassName?: string - noteClassName?: string - noteErrorClassName?: string -} - -/** - * Picker for the `operation` label. Opens with every known operation listed so - * a value can be chosen without typing, and accepts a new name via freeform entry. - * The search text starts empty — seeding it with the current value would filter - * the list down to nothing. - */ -function OperationPicker({ - currentValue, - options, - isLoading, - loadFailed, - onSelect, - onSearchChange, - onDismiss, - inputRef, - className, - listboxClassName, - noteClassName, - noteErrorClassName, -}: OperationPickerProps) { - const [search, setSearch] = useState('') - - // Each labels bar fetches its own list, and the popover and ribbon mount - // separately, so a name created a moment ago may not be in `options` here. - // List it anyway, or the picker offers to create the value already in use. - // It goes first, whether or not the request returned it, so the cap below - // can never be what drops it. - // The placeholder is not a real operation, so it stays off the list. - const listed = useMemo(() => { - const inUse = currentValue && currentValue !== DUMMY_VALUES.operation - return inUse ? [currentValue, ...options.filter(option => option !== currentValue)] : options - }, [options, currentValue]) - - const matches = search ? listed.filter(option => option.toLowerCase().includes(search)) : listed - const isNewName = search.length > 0 && !listed.some(option => option.toLowerCase() === search) - // Say why a name can't be created while it is being typed, rather than - // rejecting it after the fact next to a bar that clips the message. - const searchError = isNewName ? validateValue(search) : null - const canCreate = isNewName && !searchError - - // A name typed in full has to survive the cap too. Without this, typing an - // operation whose name is also a substring of two hundred others would leave - // it off the list, and Enter would commit whichever one happened to be first. - const shown = useMemo(() => { - const exact = matches.find(option => option.toLowerCase() === search) - const ordered = exact ? [exact, ...matches.filter(option => option !== exact)] : matches - return ordered.slice(0, MAX_LISTED) - }, [matches, search]) - - // Deferred so focus lands on whatever the user moved to before this unmounts. - const dismissAfterFocusMoves = () => { setTimeout(onDismiss, 0) } - - return ( - { setSearch(e.target.value.toLowerCase()); onSearchChange() }} - onOptionSelect={(_, data) => { if (data.optionValue) onSelect(data.optionValue) }} - onKeyDownCapture={e => { - // Fluent commits the active option on Tab. Block that, but let the key - // through so focus still moves; onBlur then ends the edit. - if (e.key === 'Tab') e.stopPropagation() - }} - onKeyDown={e => { if (e.key === 'Escape') onDismiss() }} - onBlur={dismissAfterFocusMoves} - // Fluent sizes the dropdown to the input, which cuts off longer - // operation names, and stretches it to fill the space it has. Size to - // content instead, and leave the height to the listbox class. - positioning={{ matchTargetSize: undefined, autoSize: 'width' }} - listbox={{ className: listboxClassName }} - aria-label="Operation" - data-testid="edit-label-operation" - > - {isLoading && ( - - )} - {!isLoading && loadFailed && ( - - )} - {!isLoading && !loadFailed && listed.length === 0 && !canCreate && !searchError && ( - - )} - {shown.map(option => ( - - ))} - {matches.length > MAX_LISTED && ( - - )} - {canCreate && ( - - )} - {searchError && ( - - )} - - ) -} - export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = false }: LabelsBarProps) { const styles = useLabelsBarStyles() const [isPopoverOpen, setIsPopoverOpen] = useState(false) @@ -181,8 +49,6 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f const [editValue, setEditValue] = useState('') const [error, setError] = useState('') const [existingLabels, setExistingLabels] = useState>({}) - const [labelsLoading, setLabelsLoading] = useState(true) - const [labelsFailed, setLabelsFailed] = useState(false) const editInputRef = useRef(null) // Both editors finish their work on blur, one turn later, so that focus lands // first. By then the click that took the focus may already have started a @@ -226,8 +92,7 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f operator: [...new Set([...(resp.operators ?? resp.labels.operator ?? []), ...(prev.operator || [])])], operation: [...new Set([...(resp.operations ?? resp.labels.operation ?? []), ...(prev.operation || [])])], }))) - .catch(() => setLabelsFailed(true)) - .finally(() => setLabelsLoading(false)) + .catch(() => setError('Could not load label suggestions.')) }, []) const isDummyValue = useCallback((key: string, value: string): boolean => { @@ -262,8 +127,7 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f } const handleRemoveLabel = (key: string) => { - // Don't allow removing operator or operation — they're required - if (key === 'operator' || key === 'operation') return + if (key === 'operator') return // The label may be open for editing in the popover while its chip is still // on the bar, and that edit has a save on the way. Taking the label away // has to take the save with it, or it comes back a moment later. @@ -280,9 +144,9 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f if (key === 'operator' && operatorReadOnly) return editSession.current += 1 setEditingLabel(key) - setEditValue(labels[key]) + setEditValue(labels[key] ?? '') setError('') - setTimeout(() => editInputRef.current?.focus(), 50) + if (key !== 'operator') setTimeout(() => editInputRef.current?.focus(), 50) } const handleStartEditKeyDown = (e: React.KeyboardEvent, key: string) => { @@ -320,21 +184,6 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f } const handleSelectOperation = (operation: string) => { - const known = existingLabels.operation || [] - // Values already in memory predate the current rules, and so may the one - // already in use, so both are always selectable; only a newly typed name - // has to satisfy them. - const inUse = operation === labels.operation - if (!known.includes(operation) && !inUse) { - const valueError = validateValue(operation) - if (valueError) { setError(valueError); return } - // A name only reaches the labels API once an attack has been stored under - // it, so keep it listed here or the picker forgets what it just created. - setExistingLabels(prev => ({ - ...prev, - operation: [...(prev.operation || []), operation], - })) - } commitLabels({ ...labelsRef.current, operation }) setEditingLabel(null) setEditValue('') @@ -357,11 +206,12 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f // fit remain available in the popover. const rootRef = useRef(null) const measureRef = useRef(null) + const metadataRef = useRef(null) const ICON_BUTTON_WIDTH_PX = 56 // labels icon + count badge + gap const ADD_BUTTON_WIDTH_PX = 60 // "+ Add" button const [visibleCount, setVisibleCount] = useState(Infinity) - const headerEntries = useMemo(() => Object.entries(labels), [labels]) + const headerEntries = useMemo(() => Object.entries(labels).filter(([key]) => !METADATA_KEYS.has(key)), [labels]) const labelEntries = useMemo( () => headerEntries.filter(([key]) => !METADATA_KEYS.has(key)), [headerEntries] @@ -385,7 +235,7 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f // styling as the inline row but is allowed to lay out at full // width, so each chip's offsetWidth reflects its natural size. const gap = 4 - const reserved = ICON_BUTTON_WIDTH_PX + gap + const reserved = ICON_BUTTON_WIDTH_PX + gap + (metadataRef.current?.offsetWidth ?? 0) const available = rootW - reserved let used = 0 let count = 0 @@ -417,6 +267,7 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f const observer = new ResizeObserver(check) observer.observe(root) + if (metadataRef.current) observer.observe(metadataRef.current) if (root.parentElement) observer.observe(root.parentElement) check() return () => observer.disconnect() @@ -425,29 +276,6 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f const renderValueEditor = (key: string, value: string) => { // Whatever is deferred below belongs to this edit, and only this one. const session = editSession.current - if (key === 'operation') { - return ( - <> - {key}: - setError('')} - onDismiss={() => endEdit(session)} - inputRef={editInputRef} - /> - {error && {error}} - - ) - } - const filteredSuggestions = suggestedValues .filter(v => v !== value && v.includes(editValue)) .slice(0, 8) @@ -498,7 +326,7 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f const renderLabelBadge = (key: string, value: string, idx: number) => { const isDummy = isDummyValue(key, value) const isReadOnly = key === 'operator' && operatorReadOnly - const isRequired = key === 'operator' || key === 'operation' + const isRequired = key === 'operator' // The popover renders its own editor, so only one is mounted at a time. const isEditing = editingLabel === key && !isPopoverOpen @@ -545,14 +373,15 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f role="button" tabIndex={isReadOnly ? -1 : 0} aria-disabled={isReadOnly} - aria-label={isReadOnly ? `Signed-in operator: ${value}` : `Edit ${key}${isRequired ? '' : ' label'}, currently ${value}`} + aria-label={isReadOnly ? `Signed-in operator: ${value}` : key === 'operation' && !value + ? 'Select operation' : `Edit ${key}${key === 'operation' || isRequired ? '' : ' label'}, currently ${value}`} data-testid={`label-${key}`} > - {key}: - {value} + {key === 'operation' && !value ? 'Select operation' : `${key}:`} + {value && {value}}
- {!isRequired && ( + {!isRequired && value && ( + + )} + + + ) +} diff --git a/frontend/src/components/Labels/labelDefaults.ts b/frontend/src/components/Labels/labelDefaults.ts index dad270ed72..52844155a8 100644 --- a/frontend/src/components/Labels/labelDefaults.ts +++ b/frontend/src/components/Labels/labelDefaults.ts @@ -1,4 +1,3 @@ export const DEFAULT_GLOBAL_LABELS: Record = { operator: 'roakey', - operation: 'op_trash_panda', } diff --git a/frontend/src/components/Operations/FindingDialog.styles.ts b/frontend/src/components/Operations/FindingDialog.styles.ts new file mode 100644 index 0000000000..5dc303c2d8 --- /dev/null +++ b/frontend/src/components/Operations/FindingDialog.styles.ts @@ -0,0 +1,11 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget } from '@/styles/touchTargets' + +export const useFindingDialogStyles = makeStyles({ + form: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }, + actions: { display: 'flex', justifyContent: 'flex-end', gap: tokens.spacingHorizontalS }, + button: { ...mobileTouchTarget }, + harmPicker: { minWidth: 0, width: '100%' }, + harmListbox: { maxHeight: '240px', overflowY: 'auto' }, +}) diff --git a/frontend/src/components/Operations/FindingDialog.test.tsx b/frontend/src/components/Operations/FindingDialog.test.tsx new file mode 100644 index 0000000000..c23fce8c3f --- /dev/null +++ b/frontend/src/components/Operations/FindingDialog.test.tsx @@ -0,0 +1,145 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import FindingDialog from './FindingDialog' +import { operationsApi } from '@/services/api' + +jest.mock('@/services/api', () => ({ operationsApi: { getFindingOptions: jest.fn() } })) + +beforeEach(() => { + jest.clearAllMocks() + jest.mocked(operationsApi.getFindingOptions).mockResolvedValue({ harm_types: ['Hate Speech', 'Malware', 'Other'] }) +}) + +describe('FindingDialog', () => { + it('uses the same defaults and severity choices and submits only finding fields', async () => { + const user = userEvent.setup() + const onSave = jest.fn().mockResolvedValue(undefined) + render() + expect(screen.getByRole('combobox', { name: 'Severity' })).toHaveValue('moderate') + expect(within(screen.getByRole('combobox', { name: 'Severity' })).getAllByRole('option').map(option => option.textContent)).toEqual([ + 'Critical', 'Important', 'Moderate', 'Low', 'Informational', 'Other', + ]) + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Human assessment') + await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), 'critical') + await user.type(screen.getByRole('textbox', { name: 'Description' }), 'Notes') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + await waitFor(() => { expect(onSave).toHaveBeenCalledWith({ + title: 'Human assessment', severity: 'critical', description: 'Notes', + severity_other: null, harm_type: null, harm_type_other: null, + }) }) + }) + + it('retains an edited draft on error and permits a corrected retry', async () => { + const user = userEvent.setup() + const onSave = jest.fn().mockRejectedValueOnce(new Error('Storage offline')).mockResolvedValue(undefined) + render() + expect(screen.getByRole('heading', { name: 'Edit finding' })).toBeInTheDocument() + await user.type(screen.getByRole('textbox', { name: 'Title' }), ' edited') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText('Storage offline')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Title' })).toHaveValue('Existing edited') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + await waitFor(() => { expect(screen.queryByText('Storage offline')).not.toBeInTheDocument() }) + expect(onSave).toHaveBeenCalledTimes(2) + }) + + it('requires custom Other text, preserves it on error, and clears it when returning to presets', async () => { + const user = userEvent.setup() + const onSave = jest.fn().mockRejectedValueOnce(new Error('Offline')).mockResolvedValue(undefined) + render() + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Custom') + await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), 'other') + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + await user.type(screen.getByRole('textbox', { name: 'Other severity' }), ' Team severity ') + await user.click(screen.getByRole('combobox', { name: 'Harm-type' })) + await user.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Other', exact: true })) + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + await user.type(screen.getByRole('textbox', { name: 'Other harm-type' }), ' Team harm ') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(await screen.findByText('Offline')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Other severity' })).toHaveValue(' Team severity ') + expect(screen.getByRole('textbox', { name: 'Other harm-type' })).toHaveValue(' Team harm ') + expect(onSave).toHaveBeenCalledWith({ + title: 'Custom', description: '', severity: 'other', severity_other: ' Team severity ', + harm_type: 'Other', harm_type_other: ' Team harm ', + }) + await user.selectOptions(screen.getByRole('combobox', { name: 'Severity' }), 'low') + await user.click(screen.getByRole('combobox', { name: 'Harm-type' })) + await user.click(await screen.findByRole('option', { name: 'Not set', exact: true })) + expect(screen.queryByRole('textbox', { name: 'Other severity' })).not.toBeInTheDocument() + expect(screen.queryByRole('textbox', { name: 'Other harm-type' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(onSave).toHaveBeenLastCalledWith({ + title: 'Custom', description: '', severity: 'low', severity_other: null, harm_type: null, harm_type_other: null, + }) + }) + + it('searches canonical harm choices without treating typed search text as a custom category', async () => { + const user = userEvent.setup() + const onSave = jest.fn().mockResolvedValue(undefined) + render() + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Search') + await user.type(screen.getByRole('combobox', { name: 'Harm-type' }), 'mal') + expect(await screen.findByRole('option', { name: 'Malware' })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: 'Hate Speech' })).not.toBeInTheDocument() + await user.click(screen.getByRole('option', { name: 'Malware' })) + expect(screen.getByRole('combobox', { name: 'Harm-type' })).toHaveValue('Malware') + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ harm_type: 'Malware', harm_type_other: null })) + }) + + it('reports category loading failures, retains an edit draft, and retries without clearing fields', async () => { + const user = userEvent.setup() + jest.mocked(operationsApi.getFindingOptions).mockRejectedValueOnce(new Error('Categories offline')) + const onSave = jest.fn().mockResolvedValue(undefined) + render() + expect(await screen.findByText(/Could not load harm categories: Categories offline/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled() + expect(screen.getByRole('textbox', { name: 'Other harm-type' })).toHaveValue('Team harm') + await user.click(screen.getByRole('button', { name: 'Retry harm categories' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Save finding' })).toBeEnabled()) + await user.click(screen.getByRole('button', { name: 'Save finding' })) + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ harm_type_other: 'Team harm', severity_other: 'Team severity' })) + }) + + it('preserves custom harm text when the current Other option is selected again', async () => { + const user = userEvent.setup() + render() + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Harm-type' })).toBeEnabled()) + await user.click(screen.getByRole('combobox', { name: 'Harm-type' })) + await user.click(within(await screen.findByRole('listbox')).getByRole('option', { name: 'Other', exact: true })) + expect(screen.getByRole('textbox', { name: 'Other harm-type' })).toHaveValue('Keep custom harm') + }) + + it('blocks blank custom values even when the form is submitted programmatically', async () => { + const user = userEvent.setup() + const onSave = jest.fn() + render() + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Harm-type' })).toBeEnabled()) + await user.click(screen.getByRole('textbox', { name: 'Other severity' })) + fireEvent.submit(screen.getByRole('button', { name: 'Save finding' }).closest('form')!) + expect(onSave).not.toHaveBeenCalled() + }) + + it('does not require taxonomy loading to retry an already-created finding attachment', async () => { + const user = userEvent.setup() + const onSave = jest.fn().mockResolvedValue(undefined) + render(Saved finding recovery

} + onSave={onSave} onClose={jest.fn()} />
) + expect(operationsApi.getFindingOptions).not.toHaveBeenCalled() + await user.click(screen.getByRole('button', { name: 'Retry attachment' })) + expect(onSave).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/Operations/FindingDialog.tsx b/frontend/src/components/Operations/FindingDialog.tsx new file mode 100644 index 0000000000..cfc56b2c9d --- /dev/null +++ b/frontend/src/components/Operations/FindingDialog.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react' + +import { + Button, Combobox, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, + Field, Input, MessageBar, MessageBarBody, Option, Select, Text, Textarea, +} from '@fluentui/react-components' + +import { operationsApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { FindingCreate, FindingSeverity } from '@/types' +import { FINDING_SEVERITY_LABELS } from '@/utils/findingSeverity' + +import { useFindingDialogStyles } from './FindingDialog.styles' + +const EMPTY_DRAFT: FindingCreate = { + title: '', description: '', severity: 'moderate', severity_other: null, harm_type: null, harm_type_other: null, +} + +interface FindingDialogProps { + initialValues?: FindingCreate + editing?: boolean + context?: React.ReactNode + recovery?: React.ReactNode + saveDisabled?: boolean + onSave: (draft: FindingCreate) => Promise + onClose: () => void +} + +export default function FindingDialog({ + initialValues = EMPTY_DRAFT, editing = false, context, recovery, saveDisabled = false, onSave, onClose, +}: FindingDialogProps) { + const styles = useFindingDialogStyles() + const [draft, setDraft] = useState({ ...EMPTY_DRAFT, ...initialValues }) + const [error, setError] = useState('') + const [saving, setSaving] = useState(false) + const submitting = useRef(false) + const [harmTypes, setHarmTypes] = useState(null) + const [optionsError, setOptionsError] = useState('') + const [optionsRevision, setOptionsRevision] = useState(0) + const [harmOpen, setHarmOpen] = useState(false) + const [harmSearch, setHarmSearch] = useState('') + const recovering = Boolean(recovery) + const classificationsValid = (draft.severity !== 'other' || Boolean(draft.severity_other?.trim())) + && (draft.harm_type !== 'Other' || Boolean(draft.harm_type_other?.trim())) + const canSave = !saveDisabled && (recovering || ( + Boolean(draft.title.trim()) && classificationsValid && harmTypes !== null && !optionsError + )) + + useEffect(() => { + if (recovering) return + let ignore = false + operationsApi.getFindingOptions() + .then(options => { if (!ignore) { setHarmTypes(options.harm_types); setOptionsError('') } }) + .catch((cause: unknown) => { if (!ignore) setOptionsError(toApiError(cause).detail) }) + return () => { ignore = true } + }, [optionsRevision, recovering]) + + useEffect(() => { + if (recovery || (!draft.title && !draft.description && !draft.severity_other && !draft.harm_type && !draft.harm_type_other)) return + const protectDraft = (event: BeforeUnloadEvent): void => { event.preventDefault() } + window.addEventListener('beforeunload', protectDraft) + return () => { window.removeEventListener('beforeunload', protectDraft) } + }, [draft.title, draft.description, draft.severity_other, draft.harm_type, draft.harm_type_other, recovery]) + + const save = async (): Promise => { + if (submitting.current || !canSave) return + submitting.current = true + setSaving(true) + setError('') + try { + await onSave(draft) + } catch (cause: unknown) { + setError(toApiError(cause).detail) + } finally { + submitting.current = false + setSaving(false) + } + } + + return ( + { if (!data.open && !submitting.current) onClose() }}> + + {editing ? 'Edit finding' : 'New finding'} + +
{ event.preventDefault(); void save() }}> + {context ?? Record a human assessment. No attack is required.} + {error && {error}} + {recovery ?? <> + + { setDraft(value => ({ ...value, title: data.value })) }} /> + + + + + {draft.severity === 'other' && + { setDraft(value => ({ ...value, severity_other: data.value })) }} /> + } + {optionsError && + Could not load harm categories: {optionsError}{' '} + + } + + { setHarmOpen(data.open); setHarmSearch('') }} + onChange={event => { setHarmSearch(event.target.value); setHarmOpen(true) }} + onOptionSelect={(_, data) => { + const harmType = data.optionValue + if (harmType !== '' && !harmTypes?.includes(harmType ?? '')) return + setDraft(value => ({ + ...value, harm_type: harmType || null, + harm_type_other: harmType === 'Other' ? (value.harm_type === 'Other' ? value.harm_type_other : '') : null, + })) + setHarmOpen(false) + setHarmSearch('') + }} + listbox={{ className: styles.harmListbox }}> + + {harmTypes?.filter(category => category.toLowerCase().includes(harmSearch.toLowerCase())) + .map(category => )} + {harmTypes && !harmTypes.some(category => category.toLowerCase().includes(harmSearch.toLowerCase())) + && } + + + {draft.harm_type === 'Other' && + { setDraft(value => ({ ...value, harm_type_other: data.value })) }} /> + } + +