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"
/>
-
-
- }
- onClick={() => setIsPanelOpen((open) => !open)}
- disabled={!attackResultId}
- data-testid="toggle-panel-btn"
- aria-label="Toggle conversations panel"
- aria-expanded={isPanelOpen}
- aria-controls="conversation-panel"
- />
-
+
+
+
+
+ }
+ onClick={() => setIsPanelOpen((open) => !open)}
+ disabled={!attackResultId}
+ data-testid="toggle-panel-btn"
+ aria-label="Toggle conversations panel"
+ aria-expanded={isPanelOpen}
+ aria-controls="conversation-panel"
+ />
+
+
}
+ {editDraft === null && conversationLoadIssue?.conversationId === viewedConversationId && (
+
+ {conversationLoadIssue.detail}{' '}
+ {!conversationLoadIssue.unavailable && attackResultId && viewedConversationId && (
+
+ )}
+
+
+ )}
{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 &&
+ }
+ aria-label="Link to finding" className={styles.button} disabled={disabled || !operation}
+ onClick={() => {
+ if (savedAttachment) setCreating(true)
+ else setOpen(true)
+ setSelection(null); setNotice(''); setAttachError('')
+ }} />
+ }
+ {!creating && !open && savedAttachment &&
+ {recovery}
+
+
}
+ {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)
+ }} />}
+
+ >
+ )
+}
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 && (
}
+ 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 (
+
+ )
+}
diff --git a/frontend/src/components/Operations/FindingEvidenceList.styles.ts b/frontend/src/components/Operations/FindingEvidenceList.styles.ts
new file mode 100644
index 0000000000..5cfdfff6e2
--- /dev/null
+++ b/frontend/src/components/Operations/FindingEvidenceList.styles.ts
@@ -0,0 +1,11 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTarget } from '@/styles/touchTargets'
+
+export const useFindingEvidenceListStyles = makeStyles({
+ root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS },
+ list: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM },
+ item: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS, overflowWrap: 'anywhere' },
+ actions: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalS },
+ button: { ...mobileTouchTarget },
+})
diff --git a/frontend/src/components/Operations/FindingEvidenceList.test.tsx b/frontend/src/components/Operations/FindingEvidenceList.test.tsx
new file mode 100644
index 0000000000..771de1900a
--- /dev/null
+++ b/frontend/src/components/Operations/FindingEvidenceList.test.tsx
@@ -0,0 +1,86 @@
+import React from 'react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter } from 'react-router'
+
+import { operationsApi } from '@/services/api'
+import type { FindingEvidenceItem } from '@/types'
+import FindingEvidenceList from './FindingEvidenceList'
+
+jest.mock('@/services/api', () => ({
+ operationsApi: { listFindingEvidence: jest.fn(), detachFindingEvidence: jest.fn() },
+}))
+const ID = '123e4567-e89b-12d3-a456-426614174000'
+const EVIDENCE: FindingEvidenceItem = {
+ item: { id: ID, finding_id: 'finding', conversation_id: 'source/1', attack_result_id: 'attack/1',
+ attached_at: '2026-10-08T12:00:00Z' },
+ availability: 'available', scenario_result_id: ID,
+}
+const EMPTY = { items: [], has_more: false, next_offset: null }
+
+function renderList(onDetached = jest.fn()): void {
+ render(
+
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue({ ...EMPTY, items: [EVIDENCE] })
+ jest.mocked(operationsApi.detachFindingEvidence).mockResolvedValue(undefined)
+})
+
+it('loads only on expansion and builds existing viewer/scanner evidence links', async () => {
+ const user = userEvent.setup()
+ renderList()
+ expect(operationsApi.listFindingEvidence).not.toHaveBeenCalled()
+ await user.click(screen.getByRole('button', { name: 'Evidence (1)' }))
+ expect(await screen.findByText('source/1')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Open conversation' })).toHaveAttribute('href',
+ `/attacks/attack%2F1/conversations/source%2F1?scenarioResultId=${ID}&findingEvidenceId=${ID}`)
+})
+
+it('retains unavailable identity, confirms association-only removal, and refreshes count', async () => {
+ const user = userEvent.setup()
+ const onDetached = jest.fn()
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue({ ...EMPTY, items: [{ ...EVIDENCE, availability: 'unavailable' }] })
+ renderList(onDetached)
+ await user.click(screen.getByRole('button', { name: 'Evidence (1)' }))
+ expect(await screen.findByText('Evidence unavailable')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'Open conversation' })).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Remove link: source/1' }))
+ expect(screen.getByRole('dialog')).toHaveTextContent('source/1')
+ expect(screen.getByRole('dialog')).toHaveTextContent('The conversation is not deleted')
+ expect(operationsApi.detachFindingEvidence).not.toHaveBeenCalled()
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue(EMPTY)
+ await user.click(screen.getByRole('button', { name: 'Remove evidence link' }))
+ await waitFor(() => { expect(onDetached).toHaveBeenCalledTimes(1) })
+ expect(operationsApi.detachFindingEvidence).toHaveBeenCalledWith('operation', 'finding', ID)
+})
+
+it('paginates and returns from an emptied last page after detach', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValueOnce({
+ items: [EVIDENCE], has_more: true, next_offset: 20,
+ })
+ renderList()
+ await user.click(screen.getByRole('button', { name: 'Evidence (1)' }))
+ await screen.findByText('source/1')
+ await user.click(screen.getByRole('button', { name: 'Next evidence' }))
+ await waitFor(() => { expect(operationsApi.listFindingEvidence).toHaveBeenCalledWith('operation', 'finding', { limit: 20, offset: 20 }) })
+ await screen.findByText('source/1')
+ await user.click(screen.getByRole('button', { name: 'Remove link: source/1' }))
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValueOnce(EMPTY).mockResolvedValueOnce({ ...EMPTY, items: [EVIDENCE] })
+ await user.click(screen.getByRole('button', { name: 'Remove evidence link' }))
+ await waitFor(() => { expect(operationsApi.listFindingEvidence).toHaveBeenLastCalledWith('operation', 'finding', { limit: 20, offset: 0 }) })
+})
+
+it('reports service errors without pretending the source is unavailable', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindingEvidence).mockRejectedValue(new Error('offline'))
+ renderList()
+ await user.click(screen.getByRole('button', { name: 'Evidence (1)' }))
+ expect(await screen.findByText(/Could not load evidence/)).toBeInTheDocument()
+ expect(screen.queryByText('Evidence unavailable')).not.toBeInTheDocument()
+})
diff --git a/frontend/src/components/Operations/FindingEvidenceList.tsx b/frontend/src/components/Operations/FindingEvidenceList.tsx
new file mode 100644
index 0000000000..b5e10e9813
--- /dev/null
+++ b/frontend/src/components/Operations/FindingEvidenceList.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useRef, useState } from 'react'
+
+import {
+ Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle,
+ MessageBar, MessageBarBody, Spinner, Text,
+} from '@fluentui/react-components'
+import { Link } from 'react-router'
+
+import { operationsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { FindingEvidence, FindingEvidenceListResponse } from '@/types'
+import { attackConversationRoutePath } from '@/utils/routeParams'
+
+import { useFindingEvidenceListStyles } from './FindingEvidenceList.styles'
+
+const PAGE_SIZE = 20
+
+interface FindingEvidenceListProps {
+ operationId: string
+ findingId: string
+ count: number
+ onDetached: () => void
+}
+
+export default function FindingEvidenceList({ operationId, findingId, count, onDetached }: FindingEvidenceListProps) {
+ const styles = useFindingEvidenceListStyles()
+ const [expanded, setExpanded] = useState(false)
+ const [offset, setOffset] = useState(0)
+ const [revision, setRevision] = useState(0)
+ const [page, setPage] = useState(null)
+ const [settledKey, setSettledKey] = useState('')
+ const [error, setError] = useState('')
+ const [removing, setRemoving] = useState(null)
+ const [removeError, setRemoveError] = useState('')
+ const [busy, setBusy] = useState(false)
+ const busyRef = useRef(false)
+ const triggerRef = useRef(null)
+ const wasRemoving = useRef(false)
+ const requestKey = JSON.stringify([operationId, findingId, offset, revision])
+
+ useEffect(() => {
+ if (!expanded) return
+ let ignore = false
+ operationsApi.listFindingEvidence(operationId, findingId, { limit: PAGE_SIZE, offset })
+ .then(result => {
+ if (ignore) return
+ if (result.items.length === 0 && offset > 0) {
+ setOffset(value => Math.max(0, value - PAGE_SIZE))
+ return
+ }
+ setPage(result); setError(''); setSettledKey(requestKey)
+ })
+ .catch((cause: unknown) => {
+ if (!ignore) { setError(toApiError(cause).detail); setSettledKey(requestKey) }
+ })
+ return () => { ignore = true }
+ }, [expanded, operationId, findingId, offset, requestKey])
+
+ useEffect(() => {
+ if (wasRemoving.current && !removing) triggerRef.current?.focus()
+ wasRemoving.current = removing !== null
+ }, [removing])
+
+ const detach = async (): Promise => {
+ if (!removing || busyRef.current) return
+ busyRef.current = true
+ setBusy(true)
+ setRemoveError('')
+ try {
+ await operationsApi.detachFindingEvidence(operationId, findingId, removing.id)
+ setRemoving(null)
+ setRevision(value => value + 1)
+ onDetached()
+ } catch (cause: unknown) {
+ setRemoveError(toApiError(cause).detail)
+ } finally {
+ busyRef.current = false
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
{ setExpanded(value => !value) }}>Evidence ({count})
+ {expanded &&
+ {settledKey !== requestKey ?
: error ? (
+
Could not load evidence: {error}{' '}
+ { setRevision(value => value + 1) }}>Retry evidence
+
+ ) : page?.items.length === 0 ?
No evidence attached. : (
+
+ )}
+
+ { setOffset(value => Math.max(0, value - PAGE_SIZE)) }}>Previous evidence
+ { setOffset(page?.next_offset ?? 0) }}>Next evidence
+
+
}
+
+
+ )
+}
diff --git a/frontend/src/components/Operations/OperationCreateDialog.tsx b/frontend/src/components/Operations/OperationCreateDialog.tsx
new file mode 100644
index 0000000000..70e68ec224
--- /dev/null
+++ b/frontend/src/components/Operations/OperationCreateDialog.tsx
@@ -0,0 +1,113 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router'
+import {
+ Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger,
+ Field, Input, MessageBar, MessageBarBody,
+} from '@fluentui/react-components'
+
+import { operationsApi } from '@/services/api'
+import { conflictingOperation, toApiError } from '@/services/errors'
+import type { Operation } from '@/types'
+import { useOperationsStyles } from './Operations.styles'
+
+interface OperationCreateDialogProps {
+ trigger?: React.ReactElement
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ returnFocusRef?: React.RefObject
+ onCreated: (operation: Operation) => void
+ onUseExisting?: (operation: Operation) => void
+}
+
+export default function OperationCreateDialog({
+ trigger, open: controlledOpen, onOpenChange, returnFocusRef, onCreated, onUseExisting,
+}: OperationCreateDialogProps) {
+ const styles = useOperationsStyles()
+ const [internalOpen, setInternalOpen] = useState(false)
+ const open = controlledOpen ?? internalOpen
+ const setOpen = (value: boolean): void => { setInternalOpen(value); onOpenChange?.(value) }
+ const [name, setName] = useState('')
+ const [error, setError] = useState('')
+ const [existing, setExisting] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const submitting = useRef(false)
+ const triggerRef = useRef(null)
+ const wasOpen = useRef(false)
+
+ useEffect(() => {
+ const target = returnFocusRef?.current ?? triggerRef.current
+ if (wasOpen.current && !open && target?.isConnected) target.focus()
+ wasOpen.current = open
+ }, [open, returnFocusRef])
+
+ const resetDraft = (): void => { setName(''); setError(''); setExisting(null) }
+ const finish = (operation: Operation, callback: (operation: Operation) => void): void => {
+ setOpen(false)
+ resetDraft()
+ callback(operation)
+ }
+ const create = async (): Promise => {
+ if (submitting.current) return
+ submitting.current = true
+ setSaving(true)
+ setError('')
+ setExisting(null)
+ try {
+ const operation = await operationsApi.create({ name })
+ finish(operation, onCreated)
+ } catch (cause: unknown) {
+ const conflict = conflictingOperation(cause)
+ if (conflict) setExisting(conflict)
+ else setError(toApiError(cause).detail)
+ } finally {
+ submitting.current = false
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Operations/OperationDetailPage.test.tsx b/frontend/src/components/Operations/OperationDetailPage.test.tsx
new file mode 100644
index 0000000000..049a3f4b8a
--- /dev/null
+++ b/frontend/src/components/Operations/OperationDetailPage.test.tsx
@@ -0,0 +1,313 @@
+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, Route, Routes } from 'react-router'
+
+import { attacksApi, operationsApi, scenariosApi } from '@/services/api'
+import type { Finding, FindingListItem, Operation } from '@/types'
+import OperationDetailPage from './OperationDetailPage'
+
+jest.mock('@/services/api', () => ({
+ operationsApi: {
+ get: jest.fn(), listFindings: jest.fn(), createFinding: jest.fn(),
+ updateFinding: jest.fn(), deleteFinding: jest.fn(),
+ listFindingEvidence: jest.fn(), detachFindingEvidence: jest.fn(),
+ getFindingOptions: jest.fn(),
+ },
+ attacksApi: { listAttacks: jest.fn() },
+ scenariosApi: { listRuns: jest.fn() },
+}))
+
+const OPERATION: Operation = { id: 'op-1', name: 'Red team / α%', created_at: '2026-10-06T20:00:00Z' }
+const FINDING: FindingListItem = {
+ id: 'finding-1', operation_id: 'op-1', title: 'Human assessment',
+ description: '', severity: 'informational', created_at: '2026-10-06T20:00:00Z', evidence_count: 0,
+}
+const EMPTY = { items: [], has_more: false, next_offset: null }
+const NOT_FOUND = { isAxiosError: true, response: { status: 404, data: { detail: 'missing' } } }
+
+function renderPage(route = '/operations/op-1'): void {
+ render(
+
+
+
+ } />
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(operationsApi.getFindingOptions).mockResolvedValue({ harm_types: ['Malware', 'Other'] })
+ jest.mocked(operationsApi.get).mockResolvedValue(OPERATION)
+ jest.mocked(operationsApi.listFindings).mockResolvedValue(EMPTY)
+ jest.mocked(operationsApi.createFinding).mockResolvedValue(FINDING)
+ jest.mocked(operationsApi.updateFinding).mockResolvedValue(FINDING)
+ jest.mocked(operationsApi.deleteFinding).mockResolvedValue(undefined)
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue(EMPTY)
+ jest.mocked(operationsApi.detachFindingEvidence).mockResolvedValue(undefined)
+ jest.mocked(attacksApi.listAttacks).mockResolvedValue({ items: [], pagination: { limit: 5, has_more: false } })
+ jest.mocked(scenariosApi.listRuns).mockResolvedValue({ items: [], pagination: { limit: 5, has_more: false } })
+})
+
+it('refreshes evidence counts without collapsing the expanded list after detachment', async () => {
+ const user = userEvent.setup()
+ const evidence = {
+ item: {
+ id: '123e4567-e89b-12d3-a456-426614174000', finding_id: FINDING.id, attack_result_id: 'owner',
+ conversation_id: 'source', attached_at: '2026-10-08T12:00:00Z',
+ },
+ availability: 'available' as const, scenario_result_id: null,
+ }
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [{ ...FINDING, evidence_count: 1 }] })
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue({ ...EMPTY, items: [evidence] })
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'Evidence (1)' }))
+ await user.click(await screen.findByRole('button', { name: 'Remove link: source' }))
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ jest.mocked(operationsApi.listFindingEvidence).mockResolvedValue(EMPTY)
+ await user.click(screen.getByRole('button', { name: 'Remove evidence link' }))
+ expect(await screen.findByRole('button', { name: 'Evidence (0)' })).toHaveAttribute('aria-expanded', 'true')
+ expect(await screen.findByText('No evidence attached.')).toBeInTheDocument()
+})
+
+it('links to exact-name execution history without fetching or duplicating activity', async () => {
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ renderPage()
+ expect(await screen.findByRole('heading', { name: FINDING.title })).toBeInTheDocument()
+ const link = screen.getByRole('link', { name: 'View execution history' })
+ const url = new URL(link.getAttribute('href') ?? '', 'http://localhost')
+ expect(url.pathname).toBe('/history/attacks')
+ expect(url.searchParams.get('operation')).toBe(OPERATION.name)
+ expect(screen.queryByRole('region', { name: 'Recent attacks' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('region', { name: 'Recent scanner runs' })).not.toBeInTheDocument()
+ expect(attacksApi.listAttacks).not.toHaveBeenCalled()
+ expect(scenariosApi.listRuns).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: 'Evidence (0)' })).toBeInTheDocument()
+})
+
+it('shows the operation and records a finding within it', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ expect(await screen.findByRole('heading', { level: 1, name: OPERATION.name })).toBeInTheDocument()
+ await screen.findByText(/no findings/i)
+ await user.click(screen.getByRole('button', { name: 'New finding' }))
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).queryByRole('combobox', { name: 'Operation' })).not.toBeInTheDocument()
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), FINDING.title)
+ await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Severity' }), 'informational')
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ items: [FINDING], has_more: false, next_offset: null })
+ await user.click(within(dialog).getByRole('button', { name: 'Save finding' }))
+ expect(await screen.findByRole('heading', { level: 2, name: FINDING.title })).toBeInTheDocument()
+ expect(screen.getByText('Informational')).toBeInTheDocument()
+ expect(operationsApi.createFinding).toHaveBeenCalledWith('op-1', {
+ title: FINDING.title, severity: 'informational', description: '',
+ severity_other: null, harm_type: null, harm_type_other: null,
+ })
+})
+
+it('reports a missing operation with a way back', async () => {
+ jest.mocked(operationsApi.get).mockRejectedValue(NOT_FOUND)
+ jest.mocked(operationsApi.listFindings).mockRejectedValue(NOT_FOUND)
+ renderPage()
+ expect(await screen.findByText(/operation not found/i)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /operations/i })).toHaveAttribute('href', '/operations')
+ expect(screen.queryByRole('button', { name: 'New finding' })).not.toBeInTheDocument()
+})
+
+it('retains unsaved values after a failed save', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.createFinding).mockRejectedValue(new Error('Storage unavailable'))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New finding' }))
+ const dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), 'Keep this')
+ await user.click(within(dialog).getByRole('button', { name: 'Save finding' }))
+ expect(await within(dialog).findByText(/storage unavailable/i)).toBeInTheDocument()
+ expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveValue('Keep this')
+})
+
+it.each([
+ ['Cancel', async (user: ReturnType, dialog: HTMLElement) => {
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ }],
+ ['Escape', async (user: ReturnType) => { await user.keyboard('{Escape}') }],
+])('discards the draft when the dialog is dismissed with %s', async (_, dismiss) => {
+ const user = userEvent.setup()
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New finding' }))
+ let dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), 'Draft title')
+ await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Severity' }), 'critical')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Description' }), 'Draft notes')
+ await dismiss(user, dialog)
+ await waitFor(() => { expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument() })
+ await user.click(await screen.findByRole('button', { name: 'New finding' }))
+ dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveValue('')
+ expect(within(dialog).getByRole('combobox', { name: 'Severity' })).toHaveValue('moderate')
+ expect(within(dialog).getByRole('textbox', { name: 'Description' })).toHaveValue('')
+})
+
+it('reports a confirmed save separately from a failed list refresh', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New finding' }))
+ await user.type(within(screen.getByRole('dialog')).getByRole('textbox', { name: 'Title' }), 'Assessment')
+ jest.mocked(operationsApi.listFindings).mockRejectedValue(new Error('List unavailable'))
+ await user.click(screen.getByRole('button', { name: 'Save finding' }))
+ expect(await screen.findByText(/finding saved/i)).toBeInTheDocument()
+ expect(await screen.findByText(/list unavailable/i)).toBeInTheDocument()
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+})
+
+it('disables saving a blank title and prevents duplicate submissions', async () => {
+ const user = userEvent.setup()
+ let finish!: (finding: Finding) => void
+ jest.mocked(operationsApi.createFinding).mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New finding' }))
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByRole('button', { name: 'Save finding' })).toBeDisabled()
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), 'Assessment')
+ await user.click(within(dialog).getByRole('button', { name: 'Save finding' }))
+ expect(within(dialog).getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true')
+ await user.click(within(dialog).getByRole('button', { name: 'Saving…' }))
+ await act(async () => { finish(FINDING) })
+ expect(operationsApi.createFinding).toHaveBeenCalledTimes(1)
+})
+
+it('prefills edit fields and saves only editable values', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ const edited = { ...FINDING, title: 'Edited assessment', severity: 'critical' as const, description: 'Notes' }
+ jest.mocked(operationsApi.updateFinding).mockResolvedValue(edited)
+ renderPage('/operations/op-1?offset=20')
+ await user.click(await screen.findByRole('button', { name: `Edit finding: ${FINDING.title}` }))
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveValue(FINDING.title)
+ expect(within(dialog).getByRole('combobox', { name: 'Severity' })).toHaveValue(FINDING.severity)
+ await user.clear(within(dialog).getByRole('textbox', { name: 'Title' }))
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), edited.title)
+ await user.selectOptions(within(dialog).getByRole('combobox', { name: 'Severity' }), edited.severity)
+ await user.type(within(dialog).getByRole('textbox', { name: 'Description' }), edited.description)
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [edited] })
+ await user.click(within(dialog).getByRole('button', { name: 'Save finding' }))
+ await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() })
+ await waitFor(() => { expect(screen.getByRole('heading', { name: edited.title })).toBeInTheDocument() })
+ expect(operationsApi.updateFinding).toHaveBeenCalledWith('op-1', FINDING.id, {
+ title: edited.title, severity: edited.severity, description: edited.description,
+ severity_other: null, harm_type: null, harm_type_other: null,
+ })
+
+ expect(operationsApi.createFinding).not.toHaveBeenCalled()
+ expect(await screen.findByText('Page 1')).toBeInTheDocument()
+})
+
+it('displays and preserves both custom classifications when editing a finding', async () => {
+ const user = userEvent.setup()
+ const classified: FindingListItem = {
+ ...FINDING, severity: 'other', severity_other: 'Team severity', harm_type: 'Other', harm_type_other: 'Team harm',
+ }
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [classified] })
+ jest.mocked(operationsApi.updateFinding).mockResolvedValue(classified)
+ renderPage()
+ expect(await screen.findByText('Team severity')).toBeInTheDocument()
+ expect(screen.getByText('Harm-type: Team harm')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: `Edit finding: ${FINDING.title}` }))
+ expect(screen.getByRole('textbox', { name: 'Other severity' })).toHaveValue('Team severity')
+ expect(screen.getByRole('textbox', { name: 'Other harm-type' })).toHaveValue('Team harm')
+ await user.click(screen.getByRole('button', { name: 'Save finding' }))
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
+ expect(operationsApi.updateFinding).toHaveBeenCalledWith('op-1', FINDING.id, {
+ title: FINDING.title, description: '', severity: 'other', severity_other: 'Team severity',
+ harm_type: 'Other', harm_type_other: 'Team harm',
+ })
+})
+
+it.each(['Cancel', 'Escape'])('discards edits with %s and restores the edit button focus', async dismiss => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ renderPage()
+ const edit = await screen.findByRole('button', { name: `Edit finding: ${FINDING.title}` })
+ await user.click(edit)
+ const dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), ' unsaved')
+ if (dismiss === 'Escape') await user.keyboard('{Escape}')
+ else await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(edit).toHaveFocus())
+ expect(operationsApi.updateFinding).not.toHaveBeenCalled()
+ await user.click(edit)
+ expect(within(screen.getByRole('dialog')).getByRole('textbox', { name: 'Title' })).toHaveValue(FINDING.title)
+})
+
+it('retains an edited draft on update failure', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ jest.mocked(operationsApi.updateFinding).mockRejectedValue(new Error('Update failed'))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: `Edit finding: ${FINDING.title}` }))
+ const dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Title' }), ' changed')
+ await user.click(within(dialog).getByRole('button', { name: 'Save finding' }))
+ expect(await within(dialog).findByText('Update failed')).toBeInTheDocument()
+ expect(within(dialog).getByRole('textbox', { name: 'Title' })).toHaveValue(`${FINDING.title} changed`)
+})
+
+it('requires delete confirmation and refreshes the first page after deleting its last finding', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ renderPage('/operations/op-1?offset=20')
+ const remove = await screen.findByRole('button', { name: `Delete finding: ${FINDING.title}` })
+ await user.click(remove)
+ let dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByText(/cannot be undone/i)).toHaveTextContent(FINDING.title)
+ expect(operationsApi.deleteFinding).not.toHaveBeenCalled()
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => expect(remove).toHaveFocus())
+ await user.click(remove)
+ dialog = screen.getByRole('dialog')
+ jest.mocked(operationsApi.listFindings).mockResolvedValue(EMPTY)
+ await user.click(within(dialog).getByRole('button', { name: 'Delete finding' }))
+ expect(await screen.findByText(`Finding deleted: ${FINDING.title}`)).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByText(/no findings/i)).toBeInTheDocument()
+ expect(screen.getByText('Page 1')).toBeInTheDocument()
+ })
+ expect(operationsApi.deleteFinding).toHaveBeenCalledWith('op-1', FINDING.id)
+ await waitFor(() => expect(screen.getByRole('button', { name: 'New finding' })).toHaveFocus())
+})
+
+it('keeps deletion errors visible and prevents duplicate confirmations', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValue({ ...EMPTY, items: [FINDING] })
+ let rejectDelete: (error: Error) => void = () => {}
+ jest.mocked(operationsApi.deleteFinding).mockImplementationOnce(() => new Promise((_, reject) => {
+ rejectDelete = reject
+ }))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: `Delete finding: ${FINDING.title}` }))
+ const dialog = screen.getByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Delete finding' }))
+ const pending = within(dialog).getByRole('button', { name: 'Deleting…' })
+ expect(pending).toHaveAttribute('aria-disabled', 'true')
+ await user.click(pending)
+ await user.keyboard('{Escape}')
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ await act(async () => { rejectDelete(new Error('Delete failed')) })
+ expect(await within(dialog).findByText('Delete failed')).toBeInTheDocument()
+ expect(operationsApi.deleteFinding).toHaveBeenCalledTimes(1)
+})
+
+it('pages through findings', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.listFindings).mockResolvedValueOnce({ items: [FINDING], has_more: true, next_offset: 20 })
+ renderPage()
+ await screen.findByRole('heading', { level: 2, name: FINDING.title })
+ await user.click(screen.getByRole('button', { name: 'Next' }))
+ await screen.findByText(/no findings/i)
+ expect(jest.mocked(operationsApi.listFindings).mock.calls.at(-1)).toEqual(['op-1', { limit: 20, offset: 20 }])
+ expect(screen.getByText('Page 2')).toBeInTheDocument()
+})
diff --git a/frontend/src/components/Operations/OperationDetailPage.tsx b/frontend/src/components/Operations/OperationDetailPage.tsx
new file mode 100644
index 0000000000..93d2ae5e06
--- /dev/null
+++ b/frontend/src/components/Operations/OperationDetailPage.tsx
@@ -0,0 +1,243 @@
+import { useEffect, useRef, useState } from 'react'
+import { Link, useParams, useSearchParams } from 'react-router'
+import {
+ Badge, Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle,
+ MessageBar, MessageBarBody, Spinner, Text,
+} from '@fluentui/react-components'
+import { ArrowLeftRegular } from '@fluentui/react-icons'
+
+import { DEFAULT_HISTORY_FILTERS, filtersToSearchParams } from '@/components/History/historyFilters'
+import { operationsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import { findingSeverityLabel, findingSeverityColor } from '@/utils/findingSeverity'
+import type { Finding, FindingCreate, FindingListItem, FindingListResponse, Operation } from '@/types'
+import FindingDialog from './FindingDialog'
+import FindingEvidenceList from './FindingEvidenceList'
+import { useOperationsStyles } from './Operations.styles'
+
+const PAGE_SIZE = 20
+
+type OperationState =
+ | { key: string; status: 'loaded'; operation: Operation }
+ | { key: string; status: 'missing' }
+ | { key: string; status: 'error'; error: string }
+
+export default function OperationDetailPage() {
+ const { operationId = '' } = useParams()
+ return
+}
+
+function OperationDetailContent({ operationId }: { operationId: string }) {
+ const styles = useOperationsStyles()
+ const [params, setParams] = useSearchParams()
+ const rawOffset = Number(params.get('offset') ?? 0)
+ const offset = Number.isSafeInteger(rawOffset) && rawOffset >= 0 ? rawOffset : 0
+ const [operationRevision, setOperationRevision] = useState(0)
+ const [operationState, setOperationState] = useState(null)
+ const operationKey = JSON.stringify([operationId, operationRevision])
+ const [page, setPage] = useState({ items: [], has_more: false, next_offset: null })
+ const [revision, setRevision] = useState(0)
+ const [evidenceRevision, setEvidenceRevision] = useState(0)
+ const [settledKey, setSettledKey] = useState('')
+ const [error, setError] = useState('')
+ const requestKey = JSON.stringify([operationId, offset, revision])
+ const [notice, setNotice] = useState('')
+ const [open, setOpen] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+ const [deleteError, setDeleteError] = useState('')
+ const [removing, setRemoving] = useState(false)
+ const deleteSubmitting = useRef(false)
+ const returnFocusRef = useRef(null)
+ const newFindingRef = useRef(null)
+ const wasModalOpen = useRef(false)
+ const modalOpen = open || deleting !== null
+
+ useEffect(() => {
+ if (wasModalOpen.current && !modalOpen) {
+ const target = returnFocusRef.current
+ if (target?.isConnected) target.focus()
+ else newFindingRef.current?.focus()
+ }
+ wasModalOpen.current = modalOpen
+ }, [modalOpen])
+
+ useEffect(() => {
+ let ignore = false
+ operationsApi.get(operationId)
+ .then(operation => { if (!ignore) setOperationState({ key: operationKey, status: 'loaded', operation }) })
+ .catch((cause: unknown) => {
+ if (ignore) return
+ const apiError = toApiError(cause)
+ setOperationState(apiError.status === 404
+ ? { key: operationKey, status: 'missing' }
+ : { key: operationKey, status: 'error', error: apiError.detail })
+ })
+ return () => { ignore = true }
+ }, [operationId, operationKey])
+
+ useEffect(() => {
+ let ignore = false
+ operationsApi.listFindings(operationId, { limit: PAGE_SIZE, offset })
+ .then(result => { if (!ignore) { setPage(result); setError(''); setSettledKey(requestKey) } })
+ .catch((cause: unknown) => { if (!ignore) { setError(toApiError(cause).detail); setSettledKey(requestKey) } })
+ return () => { ignore = true }
+ }, [operationId, offset, requestKey, evidenceRevision])
+
+ const navigatePage = (nextOffset: number): void => {
+ setParams(nextOffset > 0 ? { offset: String(nextOffset) } : {})
+ }
+
+ const save = async (draft: FindingCreate): Promise => {
+ const saved = editing
+ ? await operationsApi.updateFinding(operationId, editing.id, draft)
+ : await operationsApi.createFinding(operationId, draft)
+ setNotice(`Finding ${editing ? 'updated' : 'saved'}: ${saved.title}`)
+ setOpen(false)
+ setEditing(null)
+ navigatePage(0)
+ setRevision(value => value + 1)
+ }
+
+ const remove = async (): Promise => {
+ if (!deleting || deleteSubmitting.current) return
+ deleteSubmitting.current = true
+ setRemoving(true)
+ setDeleteError('')
+ try {
+ await operationsApi.deleteFinding(operationId, deleting.id)
+ setNotice(`Finding deleted: ${deleting.title}`)
+ setDeleting(null)
+ navigatePage(0)
+ setRevision(value => value + 1)
+ } catch (cause: unknown) {
+ setDeleteError(toApiError(cause).detail)
+ } finally {
+ deleteSubmitting.current = false
+ setRemoving(false)
+ }
+ }
+
+ const backLink = (
+ Operations
+ )
+ const current = operationState?.key === operationKey ? operationState : null
+
+ if (current === null) {
+ return
+ }
+ if (current.status === 'missing') {
+ return (
+
+ {backLink}
+ Operation not found
+ It may have been removed, or the link may be incorrect.
+
+ )
+ }
+ if (current.status === 'error') {
+ return (
+
+ {backLink}
+
+ Could not load operation: {current.error}{' '}
+ { setOperationRevision(value => value + 1) }}>Retry
+
+
+ )
+ }
+
+ const renderFinding = (finding: FindingListItem): React.ReactNode => (
+
+ {finding.title}
+
+
+ {findingSeverityLabel(finding)}
+
+
+
+ {finding.harm_type && Harm-type: {finding.harm_type === 'Other' ? finding.harm_type_other : finding.harm_type}}
+ {finding.description && {finding.description}
}
+ { setEvidenceRevision(value => value + 1) }} />
+
+ {
+ returnFocusRef.current = event.currentTarget
+ setEditing(finding)
+ setOpen(true)
+ }}>Edit
+ {
+ returnFocusRef.current = event.currentTarget
+ setDeleteError('')
+ setDeleting(finding)
+ }}>Delete
+
+
+ )
+
+ return (
+
+ {backLink}
+
+
+ {current.operation.name}
+
+ {
+ returnFocusRef.current = event.currentTarget
+ setEditing(null)
+ setOpen(true)
+ }}>New finding
+ {open && { setOpen(false); setEditing(null) }} />}
+
+
+ View execution history
+ Human assessments, separate from attack outcomes and scores.
+ {notice && {notice}}
+ {settledKey !== requestKey ? : error ? (
+
+ Could not load findings: {error}{' '}
+ { setRevision(value => value + 1) }}>Retry
+
+ ) : (
+ <>
+ {page.items.length === 0 ? No findings in this operation yet. Create one to record an assessment.
+ : {page.items.map(renderFinding)}
}
+
+ { navigatePage(0) }}>First
+ Page {Math.floor(offset / PAGE_SIZE) + 1}
+ { if (page.next_offset !== null) navigatePage(page.next_offset) }}>Next
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Operations/Operations.styles.ts b/frontend/src/components/Operations/Operations.styles.ts
new file mode 100644
index 0000000000..28e42c0e09
--- /dev/null
+++ b/frontend/src/components/Operations/Operations.styles.ts
@@ -0,0 +1,61 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { MINIMUM_TOUCH_TARGET_SIZE, mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useOperationsStyles = makeStyles({
+ root: {
+ flex: 1,
+ minHeight: 0,
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ padding: tokens.spacingHorizontalXXL,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalL,
+ [NARROW_VIEWPORT_QUERY]: { padding: tokens.spacingHorizontalM },
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalM,
+ },
+ heading: { margin: 0, overflowWrap: 'anywhere', minWidth: 0 },
+ link: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ gap: tokens.spacingHorizontalXS,
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ color: tokens.colorBrandForegroundLink,
+ textDecorationLine: 'none',
+ overflowWrap: 'anywhere',
+ ':hover': { textDecorationLine: 'underline' },
+ ':focus-visible': {
+ outline: `2px solid ${tokens.colorStrokeFocus2}`,
+ outlineOffset: '2px',
+ },
+ },
+ input: { width: '100%', minWidth: 0, ...mobileTouchTargetHeight },
+ form: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalL },
+ actions: { display: 'flex', justifyContent: 'end', gap: tokens.spacingHorizontalS },
+ list: { margin: 0, padding: 0, listStyleType: 'none' },
+ item: {
+ paddingTop: tokens.spacingVerticalL,
+ paddingBottom: tokens.spacingVerticalL,
+ borderBottom: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
+ overflowWrap: 'anywhere',
+ },
+ title: { margin: 0, fontSize: tokens.fontSizeBase400, fontWeight: tokens.fontWeightSemibold },
+ metadata: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalM,
+ marginTop: tokens.spacingVerticalS,
+ },
+ description: { whiteSpace: 'pre-wrap', maxWidth: '75ch', marginBottom: 0 },
+ button: { ...mobileTouchTargetHeight },
+})
diff --git a/frontend/src/components/Operations/OperationsPage.test.tsx b/frontend/src/components/Operations/OperationsPage.test.tsx
new file mode 100644
index 0000000000..90e64a987a
--- /dev/null
+++ b/frontend/src/components/Operations/OperationsPage.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
+
+import { operationsApi } from '@/services/api'
+import type { Operation } from '@/types'
+import OperationsPage from './OperationsPage'
+
+jest.mock('@/services/api', () => ({
+ operationsApi: { list: jest.fn(), create: jest.fn() },
+}))
+
+const OPERATION: Operation = { id: 'op-1', name: 'Operation A', created_at: '2026-10-06T20:00:00Z' }
+
+function LocationProbe() {
+ return {useLocation().pathname}
+}
+
+function renderPage(): void {
+ render(
+
+
+
+ } />
+ } />
+
+
+ ,
+ )
+}
+
+function conflict(existing: Operation): unknown {
+ return {
+ isAxiosError: true,
+ response: { status: 409, data: { detail: { message: 'exists', operation: existing } } },
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ jest.mocked(operationsApi.list).mockResolvedValue({ items: [OPERATION] })
+})
+
+it('lists operations as links to their pages', async () => {
+ const user = userEvent.setup()
+ renderPage()
+ await user.click(await screen.findByRole('link', { name: /Operation A/ }))
+ expect(screen.getByTestId('location')).toHaveTextContent('/operations/op-1')
+})
+
+it('shows an empty state when no operations exist', async () => {
+ jest.mocked(operationsApi.list).mockResolvedValue({ items: [] })
+ renderPage()
+ expect(await screen.findByText(/no operations yet/i)).toBeInTheDocument()
+})
+
+it('creates an operation and opens it', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.create).mockResolvedValue({ ...OPERATION, id: 'op-2', name: 'Case' })
+ renderPage()
+ await screen.findByRole('link', { name: /Operation A/ })
+ await user.click(screen.getByRole('button', { name: 'New operation' }))
+ const dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByRole('button', { name: 'Create operation' })).toBeDisabled()
+ await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), ' Case ')
+ await user.click(within(dialog).getByRole('button', { name: 'Create operation' }))
+ expect(await screen.findByTestId('location')).toHaveTextContent('/operations/op-2')
+ expect(operationsApi.create).toHaveBeenCalledWith({ name: ' Case ' })
+})
+
+it('links to the existing operation when the name is taken', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.create).mockRejectedValue(conflict(OPERATION))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New operation' }))
+ const dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), ' operation a ')
+ await user.click(within(dialog).getByRole('button', { name: 'Create operation' }))
+ expect(await within(dialog).findByText(/already exists/i)).toBeInTheDocument()
+ await user.click(within(dialog).getByRole('link', { name: 'Open Operation A' }))
+ expect(await screen.findByTestId('location')).toHaveTextContent('/operations/op-1')
+})
+
+it('discards the draft when the dialog is cancelled', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.create).mockRejectedValue(new Error('Storage unavailable'))
+ renderPage()
+ await user.click(await screen.findByRole('button', { name: 'New operation' }))
+ let dialog = screen.getByRole('dialog')
+ await user.type(within(dialog).getByRole('textbox', { name: 'Name' }), 'Draft')
+ await user.click(within(dialog).getByRole('button', { name: 'Create operation' }))
+ await within(dialog).findByText(/storage unavailable/i)
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+ await waitFor(() => { expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument() })
+ await user.click(await screen.findByRole('button', { name: 'New operation' }))
+ dialog = screen.getByRole('dialog')
+ expect(within(dialog).getByRole('textbox', { name: 'Name' })).toHaveValue('')
+ expect(within(dialog).queryByText(/storage unavailable/i)).not.toBeInTheDocument()
+})
+
+it('offers a retry when operations cannot be loaded', async () => {
+ const user = userEvent.setup()
+ jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('List unavailable'))
+ renderPage()
+ expect(await screen.findByText(/list unavailable/i)).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(await screen.findByRole('link', { name: /Operation A/ })).toBeInTheDocument()
+})
diff --git a/frontend/src/components/Operations/OperationsPage.tsx b/frontend/src/components/Operations/OperationsPage.tsx
new file mode 100644
index 0000000000..aea72e418a
--- /dev/null
+++ b/frontend/src/components/Operations/OperationsPage.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useState } from 'react'
+import { Link, useNavigate } from 'react-router'
+import {
+ Button, MessageBar, MessageBarBody, Spinner, Text,
+} from '@fluentui/react-components'
+
+import { operationsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { Operation } from '@/types'
+import { useOperationsStyles } from './Operations.styles'
+import OperationCreateDialog from './OperationCreateDialog'
+
+export default function OperationsPage() {
+ const styles = useOperationsStyles()
+ const navigate = useNavigate()
+ const [operations, setOperations] = useState([])
+ const [settledRevision, setSettledRevision] = useState(-1)
+ const [revision, setRevision] = useState(0)
+ const [error, setError] = useState('')
+ const loading = settledRevision !== revision
+
+ useEffect(() => {
+ let ignore = false
+ operationsApi.list()
+ .then(result => { if (!ignore) { setOperations(result.items); setError(''); setSettledRevision(revision) } })
+ .catch((cause: unknown) => { if (!ignore) { setError(toApiError(cause).detail); setSettledRevision(revision) } })
+ return () => { ignore = true }
+ }, [revision])
+
+ return (
+
+
+ Operations
+ New operation}
+ onCreated={operation => { void navigate(`/operations/${encodeURIComponent(operation.id)}`) }}
+ />
+
+ Engagements that group human findings.
+ {loading ? : error ? (
+
+ Could not load operations: {error}{' '}
+ { setRevision(value => value + 1) }}>Retry
+
+ ) : operations.length === 0 ? (
+ No operations yet. Create one to start recording findings.
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx
index bc83c3e581..01a93ee877 100644
--- a/frontend/src/components/Sidebar/Navigation.test.tsx
+++ b/frontend/src/components/Sidebar/Navigation.test.tsx
@@ -141,12 +141,26 @@ describe("Navigation", () => {
"Home",
"Chat",
"History",
+ "Operations",
"Scanner",
"Registry",
"Configuration",
]);
});
+ it("marks Operations current and navigates to its view", async () => {
+ const user = userEvent.setup();
+ const onNavigate = jest.fn();
+ renderWithProvider(
+ ,
+ );
+
+ const button = screen.getByRole("button", { name: "Operations" });
+ expect(button).toHaveAttribute("aria-current", "page");
+ await user.click(button);
+ expect(onNavigate).toHaveBeenCalledWith("operations");
+ });
+
it("marks History current and navigates to its tabbed view", async () => {
const user = userEvent.setup();
const onNavigate = jest.fn();
diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx
index 2bfee46427..a8fbf4fc3e 100644
--- a/frontend/src/components/Sidebar/Navigation.tsx
+++ b/frontend/src/components/Sidebar/Navigation.tsx
@@ -13,6 +13,7 @@ import {
HomeRegular,
SettingsRegular,
HistoryRegular,
+ BriefcaseRegular,
PersonFeedbackRegular,
ScriptRegular,
TargetRegular,
@@ -29,6 +30,7 @@ export type ViewName =
| 'home'
| 'chat'
| 'history'
+ | 'operations'
| 'registry'
| 'configuration'
| 'scenarios'
@@ -101,6 +103,17 @@ export default function Navigation({
onClick={() => onNavigate('history')}
/>
+ }
+ title="Operations"
+ aria-label="Operations"
+ aria-current={currentView === 'operations' ? 'page' : undefined}
+ onClick={() => onNavigate('operations')}
+ />
+
{
+ describe("operationsApi", () => {
+ it("reads canonical harm categories without copying the taxonomy into the frontend", async () => {
+ const options = { harm_types: ["Malware", "Other"] };
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: options });
+ expect(await operationsApi.getFindingOptions()).toEqual(options);
+ expect(apiClient.get).toHaveBeenCalledWith("/operations/finding-options");
+ });
+ it("encodes evidence scope, searches literally, and preserves duplicate feedback", async () => {
+ const page = { items: [], has_more: false, next_offset: null };
+ (apiClient.get as jest.Mock).mockResolvedValue({ data: page });
+ expect(await operationsApi.searchFindings("a/b", { limit: 20, offset: 0, title: "%_" })).toEqual(page);
+ expect(apiClient.get).toHaveBeenCalledWith("/operations/a%2Fb/findings", {
+ params: { limit: 20, offset: 0, title: "%_" },
+ });
+ const request = { attack_result_id: "attack", conversation_id: "conversation" };
+ const result = { item: { id: "evidence" }, created: false };
+ (apiClient.post as jest.Mock).mockResolvedValue({ data: result });
+ expect(await operationsApi.attachFindingEvidence("a/b", "c/d", request)).toEqual(result);
+ expect(apiClient.post).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd/evidence", request);
+ expect(await operationsApi.listFindingEvidence("a/b", "c/d", { limit: 20, offset: 20 })).toEqual(page);
+ expect(apiClient.get).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd/evidence", {
+ params: { limit: 20, offset: 20 },
+ });
+ await operationsApi.detachFindingEvidence("a/b", "c/d", "e/f");
+ expect(apiClient.delete).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd/evidence/e%2Ff");
+ });
+ it("nests finding requests under an encoded operation ID", async () => {
+ const request = { title: "Assessment", description: "", severity: "low" as const };
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({ data: { name: "Case" } });
+ await operationsApi.create({ name: " Case / α% " });
+ expect(apiClient.post).toHaveBeenCalledWith("/operations", { name: " Case / α% " });
+ (apiClient.post as jest.Mock).mockResolvedValueOnce({ data: request });
+ expect(await operationsApi.createFinding("a/b", request)).toEqual(request);
+ expect(apiClient.post).toHaveBeenCalledWith("/operations/a%2Fb/findings", request);
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: { items: [], has_more: false, next_offset: null } });
+ await operationsApi.listFindings("a/b", { limit: 20, offset: 0 });
+ expect(apiClient.get).toHaveBeenCalledWith("/operations/a%2Fb/findings", {
+ params: { limit: 20, offset: 0 },
+ });
+ (apiClient.put as jest.Mock).mockResolvedValueOnce({ data: request });
+ expect(await operationsApi.updateFinding("a/b", "c/d", request)).toEqual(request);
+ expect(apiClient.put).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd", request);
+ (apiClient.delete as jest.Mock).mockResolvedValueOnce({ data: undefined });
+ expect(await operationsApi.deleteFinding("a/b", "c/d")).toBeUndefined();
+ expect(apiClient.delete).toHaveBeenCalledWith("/operations/a%2Fb/findings/c%2Fd");
+ });
+ });
// Interceptor functions are registered at module-load time.
// Capture them before beforeEach's clearAllMocks wipes the call records.
const requestInterceptor = (apiClient.interceptors.request.use as jest.Mock).mock.calls[0]?.[0];
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 4e4cde69df..b4f1097110 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -53,10 +53,75 @@ import type {
ManualScoreRequest,
UpdateAttackRequest,
SaveConversationRequest,
+ Finding,
+ FindingCreate,
+ FindingListResponse,
+ FindingOptionsResponse,
+ FindingEvidenceCreateRequest,
+ FindingEvidenceAttachResponse,
+ FindingEvidenceListResponse,
+ Operation,
+ OperationCreate,
+ OperationListResponse,
} from '../types'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
+const operationPath = (operationId: string): string => `/operations/${encodeURIComponent(operationId)}`
+
+export const operationsApi = {
+ getFindingOptions: async (): Promise => {
+ return (await apiClient.get('/operations/finding-options')).data
+ },
+ list: async (): Promise => {
+ return (await apiClient.get('/operations')).data
+ },
+ get: async (operationId: string): Promise => {
+ return (await apiClient.get(operationPath(operationId))).data
+ },
+ create: async (request: OperationCreate): Promise => {
+ return (await apiClient.post('/operations', request)).data
+ },
+ listFindings: async (
+ operationId: string, params: { limit: number; offset: number },
+ ): Promise => {
+ return (await apiClient.get(`${operationPath(operationId)}/findings`, { params })).data
+ },
+ createFinding: async (operationId: string, request: FindingCreate): Promise => {
+ return (await apiClient.post(`${operationPath(operationId)}/findings`, request)).data
+ },
+ updateFinding: async (operationId: string, findingId: string, request: FindingCreate): Promise => {
+ return (await apiClient.put(`${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}`, request)).data
+ },
+ deleteFinding: async (operationId: string, findingId: string): Promise => {
+ await apiClient.delete(`${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}`)
+ },
+ searchFindings: async (
+ operationId: string, params: { limit: number; offset: number; title: string },
+ ): Promise => {
+ return (await apiClient.get(`${operationPath(operationId)}/findings`, { params })).data
+ },
+ attachFindingEvidence: async (
+ operationId: string, findingId: string, request: FindingEvidenceCreateRequest,
+ ): Promise => {
+ return (await apiClient.post(
+ `${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}/evidence`, request,
+ )).data
+ },
+ listFindingEvidence: async (
+ operationId: string, findingId: string, params: { limit: number; offset: number },
+ ): Promise => {
+ return (await apiClient.get(
+ `${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}/evidence`, { params },
+ )).data
+ },
+ detachFindingEvidence: async (operationId: string, findingId: string, evidenceId: string): Promise => {
+ await apiClient.delete(
+ `${operationPath(operationId)}/findings/${encodeURIComponent(findingId)}/evidence/${encodeURIComponent(evidenceId)}`,
+ )
+ },
+}
+
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
diff --git a/frontend/src/services/errors.ts b/frontend/src/services/errors.ts
index b328a4d202..bf8056c3d1 100644
--- a/frontend/src/services/errors.ts
+++ b/frontend/src/services/errors.ts
@@ -1,5 +1,21 @@
import type { AxiosError } from 'axios'
+import type { Operation } from '@/types'
+
+export function conflictingOperation(cause: unknown): Operation | null {
+ if (!isAxiosError(cause) || cause.response?.status !== 409) return null
+ const data: unknown = cause.response.data
+ if (typeof data !== 'object' || data === null || !('detail' in data)) return null
+ const detail = data.detail
+ if (typeof detail !== 'object' || detail === null || !('operation' in detail)) return null
+ const operation = detail.operation
+ if (typeof operation !== 'object' || operation === null
+ || !('id' in operation) || typeof operation.id !== 'string'
+ || !('name' in operation) || typeof operation.name !== 'string'
+ || !('created_at' in operation) || typeof operation.created_at !== 'string') return null
+ return { id: operation.id, name: operation.name, created_at: operation.created_at }
+}
+
/**
* Normalized error from any API call.
*
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index ea5333de68..8bb96beea7 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -1267,3 +1267,76 @@ export interface RuntimeStatus {
outcome: string
message: string
}
+export type FindingSeverity = 'critical' | 'important' | 'moderate' | 'low' | 'informational' | 'other'
+
+export interface OperationCreate {
+ name: string
+}
+
+export interface Operation extends OperationCreate {
+ id: string
+ created_at: string
+}
+
+export interface OperationListResponse {
+ items: Operation[]
+}
+
+export interface FindingCreate {
+ title: string
+ description: string
+ severity: FindingSeverity
+ severity_other?: string | null
+ harm_type?: string | null
+ harm_type_other?: string | null
+}
+
+export interface FindingOptionsResponse {
+ harm_types: string[]
+}
+
+export interface Finding extends FindingCreate {
+ id: string
+ operation_id: string
+ created_at: string
+}
+
+export interface FindingListResponse {
+ items: FindingListItem[]
+ has_more: boolean
+ next_offset: number | null
+}
+
+export interface FindingListItem extends Finding {
+ evidence_count: number
+}
+
+export interface FindingEvidenceCreateRequest {
+ attack_result_id: string
+ conversation_id: string
+}
+
+export interface FindingEvidence {
+ id: string
+ finding_id: string
+ conversation_id: string
+ attack_result_id: string
+ attached_at: string
+}
+
+export interface FindingEvidenceAttachResponse {
+ item: FindingEvidence
+ created: boolean
+}
+
+export interface FindingEvidenceItem {
+ item: FindingEvidence
+ availability: 'available' | 'unavailable'
+ scenario_result_id: string | null
+}
+
+export interface FindingEvidenceListResponse {
+ items: FindingEvidenceItem[]
+ has_more: boolean
+ next_offset: number | null
+}
diff --git a/frontend/src/utils/findingSeverity.test.ts b/frontend/src/utils/findingSeverity.test.ts
new file mode 100644
index 0000000000..47463f8341
--- /dev/null
+++ b/frontend/src/utils/findingSeverity.test.ts
@@ -0,0 +1,20 @@
+import { findingSeverityColor, findingSeverityLabel, FINDING_SEVERITY_LABELS } from './findingSeverity'
+import type { FindingSeverity } from '@/types'
+
+it.each<[FindingSeverity, string, string]>([
+ ['critical', 'Critical', 'danger'],
+ ['important', 'Important', 'warning'],
+ ['moderate', 'Moderate', 'warning'],
+ ['low', 'Low', 'informative'],
+ ['informational', 'Informational', 'subtle'],
+ ['other', 'Other', 'subtle'],
+])('uses the Operations label and badge color for %s', (severity, label, color) => {
+ expect(FINDING_SEVERITY_LABELS[severity]).toBe(label)
+ expect(findingSeverityColor(severity)).toBe(color)
+})
+
+it('shows the custom assessment as a neutral badge instead of assigning its text a preset priority', () => {
+ expect(findingSeverityLabel({ severity: 'other', severity_other: 'Critical' })).toBe('Critical')
+ expect(findingSeverityColor('other')).toBe('subtle')
+ expect(findingSeverityLabel({ severity: 'low' })).toBe('Low')
+})
diff --git a/frontend/src/utils/findingSeverity.ts b/frontend/src/utils/findingSeverity.ts
new file mode 100644
index 0000000000..fd2babd206
--- /dev/null
+++ b/frontend/src/utils/findingSeverity.ts
@@ -0,0 +1,16 @@
+import type { FindingCreate, FindingSeverity } from '@/types'
+
+export const FINDING_SEVERITY_LABELS: Record = {
+ critical: 'Critical', important: 'Important', moderate: 'Moderate',
+ low: 'Low', informational: 'Informational', other: 'Other',
+}
+
+export function findingSeverityLabel(finding: Pick): string {
+ return finding.severity === 'other' ? finding.severity_other ?? FINDING_SEVERITY_LABELS.other : FINDING_SEVERITY_LABELS[finding.severity]
+}
+
+export function findingSeverityColor(severity: FindingSeverity): 'danger' | 'warning' | 'informative' | 'subtle' {
+ if (severity === 'critical') return 'danger'
+ if (severity === 'important' || severity === 'moderate') return 'warning'
+ return severity === 'informational' || severity === 'other' ? 'subtle' : 'informative'
+}
diff --git a/frontend/src/utils/routeParams.test.ts b/frontend/src/utils/routeParams.test.ts
index 21a8af4807..ca32102114 100644
--- a/frontend/src/utils/routeParams.test.ts
+++ b/frontend/src/utils/routeParams.test.ts
@@ -5,6 +5,7 @@ import {
scenarioRunAttackRoutePath,
scenarioRunProvenance,
scenarioRunRoutePath,
+ findingEvidenceOrigin,
} from './routeParams'
const SCENARIO_RESULT_ID = '123e4567-e89b-12d3-a456-426614174000'
@@ -25,6 +26,18 @@ describe('routerPathParamValue', () => {
})
describe('scenario run provenance routes', () => {
+ it('builds evidence-origin routes preserving scanner provenance', () => {
+ expect(attackConversationRoutePath('attack/1', 'conversation/1', SCENARIO_RESULT_ID, SCENARIO_RESULT_ID)).toBe(
+ `/attacks/attack%2F1/conversations/conversation%2F1?scenarioResultId=${SCENARIO_RESULT_ID}&findingEvidenceId=${SCENARIO_RESULT_ID}`,
+ )
+ expect(findingEvidenceOrigin(new URLSearchParams(`findingEvidenceId=${SCENARIO_RESULT_ID}`))).toBe(SCENARIO_RESULT_ID)
+ })
+ it.each(['findingEvidenceId=bad', `findingEvidenceId=${SCENARIO_RESULT_ID}&findingEvidenceId=${SCENARIO_RESULT_ID}`, ''])(
+ 'rejects invalid evidence origin %s', (query: string) => {
+ expect(findingEvidenceOrigin(new URLSearchParams(query))).toBeNull()
+ expect(attackConversationRoutePath('a', 'c', null, 'bad')).toBe('/attacks/a/conversations/c')
+ },
+ )
it('reads one canonical UUID and ignores unrelated query values', () => {
const params = new URLSearchParams(`tab=messages&scenarioResultId=${SCENARIO_RESULT_ID}`)
diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts
index 8af3d0ccbf..3108e6aebf 100644
--- a/frontend/src/utils/routeParams.ts
+++ b/frontend/src/utils/routeParams.ts
@@ -1,4 +1,5 @@
const SCENARIO_RESULT_ID_QUERY_KEY = 'scenarioResultId'
+const FINDING_EVIDENCE_ID_QUERY_KEY = 'findingEvidenceId'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
@@ -19,9 +20,16 @@ export function scenarioRunProvenance(searchParams: URLSearchParams): string | n
if (values.length !== 1 || !UUID_PATTERN.test(values[0])) {
return null
}
+
return values[0]
}
+/** Returns only one validated evidence identity, never a caller-supplied return URL. */
+export function findingEvidenceOrigin(searchParams: URLSearchParams): string | null {
+ const values = searchParams.getAll(FINDING_EVIDENCE_ID_QUERY_KEY)
+ return values.length === 1 && UUID_PATTERN.test(values[0]) ? values[0] : null
+}
+
/** Builds an attack-detail route with optional bounded scenario-run provenance. */
export function attackRoutePath(
attackResultId: string,
@@ -38,11 +46,14 @@ export function attackConversationRoutePath(
attackResultId: string,
conversationId: string,
scenarioResultId?: string | null,
+ findingEvidenceId?: string | null,
): string {
- return appendScenarioRunProvenance(
+ const path = appendScenarioRunProvenance(
`/attacks/${encodeURIComponent(attackResultId)}/conversations/${encodeURIComponent(conversationId)}`,
scenarioResultId,
)
+ if (!findingEvidenceId || !UUID_PATTERN.test(findingEvidenceId)) return path
+ return `${path}${path.includes('?') ? '&' : '?'}${FINDING_EVIDENCE_ID_QUERY_KEY}=${encodeURIComponent(findingEvidenceId)}`
}
/** Builds the route for one scenario run. Callers must pass a trusted persisted ID. */
diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py
index 25fd7e0998..1a563eda6e 100644
--- a/pyrit/backend/main.py
+++ b/pyrit/backend/main.py
@@ -37,6 +37,7 @@
labels,
media,
message_sends,
+ operations,
scenarios,
scorers,
scores,
@@ -127,6 +128,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(scorers.router, prefix="/api", tags=["scorers"])
app.include_router(converters.router, prefix="/api", tags=["converters"])
app.include_router(datasets.router, prefix="/api", tags=["datasets"])
+app.include_router(operations.router, prefix="/api", tags=["operations"])
app.include_router(scenarios.router, prefix="/api", tags=["scenarios"])
app.include_router(initializers.router, prefix="/api", tags=["initializers"])
app.include_router(labels.router, prefix="/api", tags=["labels"])
diff --git a/pyrit/backend/models/operations.py b/pyrit/backend/models/operations.py
new file mode 100644
index 0000000000..7916d781ad
--- /dev/null
+++ b/pyrit/backend/models/operations.py
@@ -0,0 +1,81 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from enum import Enum
+from uuid import UUID
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from pyrit.models import Finding, FindingEvidence, Operation
+from pyrit.models.harm_category import HarmCategory
+
+
+class FindingOptionsResponse(BaseModel):
+ """Canonical harm categories for the fixed finding form."""
+
+ harm_types: list[HarmCategory]
+
+
+class OperationListResponse(BaseModel):
+ """All operations available as homes for findings."""
+
+ items: list[Operation]
+
+
+class FindingListItem(Finding):
+ """A finding with a bounded bulk-derived association count."""
+
+ evidence_count: int = Field(ge=0)
+
+
+class FindingListResponse(BaseModel):
+ """A bounded page of findings within one operation."""
+
+ items: list[FindingListItem]
+ has_more: bool
+ next_offset: int | None
+
+
+class FindingEvidenceCreateRequest(BaseModel):
+ """The persisted viewer identity to attach."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ attack_result_id: UUID
+ conversation_id: str = Field(min_length=1, max_length=128)
+
+
+class FindingEvidenceAttachResponse(BaseModel):
+ """An attachment and whether this request created it."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ item: FindingEvidence
+ created: bool
+
+
+class EvidenceAvailability(str, Enum):
+ """Current viewer addressability of the live reference."""
+
+ AVAILABLE = "available"
+ UNAVAILABLE = "unavailable"
+
+
+class FindingEvidenceItem(BaseModel):
+ """An association with read-time source and scanner context."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ item: FindingEvidence
+ availability: EvidenceAvailability
+ scenario_result_id: UUID | None
+
+
+class FindingEvidenceListResponse(BaseModel):
+ """A bounded page of live evidence references."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ items: list[FindingEvidenceItem]
+ has_more: bool
+ next_offset: int | None
diff --git a/pyrit/backend/routes/operations.py b/pyrit/backend/routes/operations.py
new file mode 100644
index 0000000000..e3a1865f6a
--- /dev/null
+++ b/pyrit/backend/routes/operations.py
@@ -0,0 +1,208 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from collections.abc import Awaitable
+from typing import Annotated, TypeVar
+from uuid import UUID
+
+from fastapi import APIRouter, HTTPException, Query, Response, status
+
+from pyrit.backend.models.operations import (
+ FindingEvidenceAttachResponse,
+ FindingEvidenceCreateRequest,
+ FindingEvidenceListResponse,
+ FindingListResponse,
+ FindingOptionsResponse,
+ OperationListResponse,
+)
+from pyrit.backend.services.operation_service import (
+ DuplicateOperationError,
+ FindingEvidenceAttributionError,
+ FindingEvidenceNotFoundError,
+ FindingNotFoundError,
+ OperationNotFoundError,
+ OperationService,
+)
+from pyrit.models import Finding, FindingCreate, Operation, OperationCreate
+from pyrit.models.harm_category import HarmCategory
+
+T = TypeVar("T")
+
+router = APIRouter(prefix="/operations", tags=["operations"])
+
+
+@router.post("", response_model=Operation, status_code=status.HTTP_201_CREATED)
+async def create_operation_async(request: OperationCreate) -> Operation:
+ """
+ Create an operation with a name unique after trimming and case folding.
+
+ Returns:
+ Operation: The saved operation.
+
+ Raises:
+ HTTPException: 409 with the existing operation when the name is taken.
+ """
+ try:
+ return await OperationService().create_async(request)
+ except DuplicateOperationError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={"message": str(exc), "operation": exc.existing.model_dump(mode="json")},
+ ) from exc
+
+
+@router.get("", response_model=OperationListResponse)
+async def list_operations_async() -> OperationListResponse:
+ """
+ List all operations.
+
+ Returns:
+ OperationListResponse: Operations in name order.
+ """
+ return OperationListResponse(items=await OperationService().list_async())
+
+
+@router.get("/finding-options", response_model=FindingOptionsResponse)
+async def get_finding_options_async() -> FindingOptionsResponse:
+ """
+ Read the canonical harm categories for findings.
+
+ Returns:
+ FindingOptionsResponse: Selectable categories, including Other.
+ """
+ return FindingOptionsResponse(harm_types=list(HarmCategory))
+
+
+@router.get("/{operation_id}", response_model=Operation)
+async def get_operation_async(operation_id: UUID) -> Operation:
+ """
+ Read one operation.
+
+ Returns:
+ Operation: The requested operation.
+ """
+ return await _require_operation_async(OperationService().get_async(operation_id))
+
+
+@router.post("/{operation_id}/findings", response_model=Finding, status_code=status.HTTP_201_CREATED)
+async def create_operation_finding_async(*, operation_id: UUID, request: FindingCreate) -> Finding:
+ """
+ Record a finding within an operation.
+
+ Returns:
+ Finding: The saved finding.
+ """
+ return await _require_operation_async(
+ OperationService().create_finding_async(operation_id=operation_id, request=request)
+ )
+
+
+@router.get("/{operation_id}/findings", response_model=FindingListResponse)
+async def list_operation_findings_async(
+ *,
+ operation_id: UUID,
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
+ offset: Annotated[int, Query(ge=0)] = 0,
+ title: str | None = None,
+) -> FindingListResponse:
+ """
+ List one page of an operation's findings, by severity and then newest first.
+
+ Returns:
+ FindingListResponse: A bounded page.
+ """
+ return await _require_operation_async(
+ OperationService().list_findings_async(operation_id=operation_id, limit=limit, offset=offset, title_query=title)
+ )
+
+
+@router.put("/{operation_id}/findings/{finding_id}", response_model=Finding)
+async def update_operation_finding_async(*, operation_id: UUID, finding_id: UUID, request: FindingCreate) -> Finding:
+ """
+ Replace the editable fields of a finding in its operation.
+
+ Returns:
+ Finding: The updated finding.
+ """
+ return await _require_operation_async(
+ OperationService().update_finding_async(operation_id=operation_id, finding_id=finding_id, request=request)
+ )
+
+
+@router.delete("/{operation_id}/findings/{finding_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_operation_finding_async(*, operation_id: UUID, finding_id: UUID) -> Response:
+ """
+ Permanently remove a finding from its operation.
+
+ Returns:
+ Response: An empty success response.
+ """
+ await _require_operation_async(
+ OperationService().delete_finding_async(operation_id=operation_id, finding_id=finding_id)
+ )
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+@router.post("/{operation_id}/findings/{finding_id}/evidence", response_model=FindingEvidenceAttachResponse)
+async def attach_finding_evidence_async(
+ *, operation_id: UUID, finding_id: UUID, request: FindingEvidenceCreateRequest, response: Response
+) -> FindingEvidenceAttachResponse:
+ """
+ Attach a saved conversation or return its unchanged existing association.
+
+ Returns:
+ FindingEvidenceAttachResponse: The association and creation indicator.
+ """
+ result = await _require_operation_async(
+ OperationService().attach_finding_evidence_async(
+ operation_id=operation_id, finding_id=finding_id, request=request
+ )
+ )
+ response.status_code = status.HTTP_201_CREATED if result.created else status.HTTP_200_OK
+ return result
+
+
+@router.get("/{operation_id}/findings/{finding_id}/evidence", response_model=FindingEvidenceListResponse)
+async def list_finding_evidence_async(
+ *,
+ operation_id: UUID,
+ finding_id: UUID,
+ limit: Annotated[int, Query(ge=1, le=100)] = 20,
+ offset: Annotated[int, Query(ge=0)] = 0,
+) -> FindingEvidenceListResponse:
+ """
+ Read a bounded evidence page with current source availability.
+
+ Returns:
+ FindingEvidenceListResponse: The page and continuation offset.
+ """
+ return await _require_operation_async(
+ OperationService().list_finding_evidence_async(
+ operation_id=operation_id, finding_id=finding_id, limit=limit, offset=offset
+ )
+ )
+
+
+@router.delete("/{operation_id}/findings/{finding_id}/evidence/{evidence_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def detach_finding_evidence_async(*, operation_id: UUID, finding_id: UUID, evidence_id: UUID) -> Response:
+ """
+ Remove an association without deleting the conversation.
+
+ Returns:
+ Response: An empty success response.
+ """
+ await _require_operation_async(
+ OperationService().detach_finding_evidence_async(
+ operation_id=operation_id, finding_id=finding_id, evidence_id=evidence_id
+ )
+ )
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
+
+async def _require_operation_async(pending: Awaitable[T]) -> T:
+ try:
+ return await pending
+ except (OperationNotFoundError, FindingNotFoundError, FindingEvidenceNotFoundError) as exc:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
+ except FindingEvidenceAttributionError as exc:
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
diff --git a/pyrit/backend/services/operation_service.py b/pyrit/backend/services/operation_service.py
new file mode 100644
index 0000000000..7cccc2c0ea
--- /dev/null
+++ b/pyrit/backend/services/operation_service.py
@@ -0,0 +1,252 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from uuid import UUID
+
+from pyrit.backend.models.operations import (
+ EvidenceAvailability,
+ FindingEvidenceAttachResponse,
+ FindingEvidenceCreateRequest,
+ FindingEvidenceItem,
+ FindingEvidenceListResponse,
+ FindingListItem,
+ FindingListResponse,
+)
+from pyrit.memory import CentralMemory
+from pyrit.models import Finding, FindingCreate, FindingEvidence, Operation, OperationCreate
+
+
+class OperationNotFoundError(LookupError):
+ """Raised when a request names an operation that does not exist."""
+
+
+class FindingNotFoundError(LookupError):
+ """Raised when a finding does not belong to the requested operation."""
+
+
+class FindingEvidenceNotFoundError(LookupError):
+ """Raised when the requested association or source cannot be viewed."""
+
+
+class FindingEvidenceAttributionError(ValueError):
+ """Raised when the source lacks the finding's exact Operation attribution."""
+
+
+class DuplicateOperationError(ValueError):
+ """Raised when an operation name collides with an existing record."""
+
+ def __init__(self, existing: Operation) -> None:
+ """Keep the existing record so callers can point users to it."""
+ super().__init__(f"Operation '{existing.name}' already exists.")
+ self.existing = existing
+
+
+class OperationService:
+ """Coordinate operations and the findings recorded within them."""
+
+ async def create_async(self, request: OperationCreate) -> Operation:
+ """
+ Save a new operation unless its normalized name is already taken.
+
+ Returns:
+ Operation: The saved operation.
+
+ Raises:
+ DuplicateOperationError: If an operation with the same normalized name exists.
+ """
+ memory = CentralMemory.get_memory_instance()
+ operation = Operation(name=request.name)
+ saved = await memory.add_operation_async(operation)
+ if saved.id != operation.id:
+ raise DuplicateOperationError(saved)
+ return saved
+
+ async def list_async(self) -> list[Operation]:
+ """
+ Read every operation in name order.
+
+ Returns:
+ list[Operation]: All operations.
+ """
+ return await CentralMemory.get_memory_instance().get_operations_async()
+
+ async def get_async(self, operation_id: UUID) -> Operation:
+ """
+ Read one operation.
+
+ Returns:
+ Operation: The requested operation.
+
+ Raises:
+ OperationNotFoundError: If no operation has this ID.
+ """
+ operations = await CentralMemory.get_memory_instance().get_operations_async(operation_id=operation_id)
+ if not operations:
+ raise OperationNotFoundError(f"Operation '{operation_id}' not found.")
+ return operations[0]
+
+ async def create_finding_async(self, *, operation_id: UUID, request: FindingCreate) -> Finding:
+ """
+ Save a finding within an existing operation.
+
+ Returns:
+ Finding: The saved finding.
+ """
+ await self.get_async(operation_id)
+ finding = Finding(operation_id=operation_id, **request.model_dump())
+ return await CentralMemory.get_memory_instance().add_finding_async(finding)
+
+ async def list_findings_async(
+ self, *, operation_id: UUID, limit: int, offset: int, title_query: str | None = None
+ ) -> FindingListResponse:
+ """
+ Read one page of an operation's findings and detect whether another is available.
+
+ Returns:
+ FindingListResponse: The page and continuation offset.
+ """
+ await self.get_async(operation_id)
+ findings = await CentralMemory.get_memory_instance().get_findings_async(
+ operation_id=operation_id, limit=limit + 1, offset=offset, title_query=title_query
+ )
+ has_more = len(findings) > limit
+ page = findings[:limit]
+ counts = await CentralMemory.get_memory_instance().get_finding_evidence_counts_async(
+ finding_ids=[finding.id for finding in page]
+ )
+ return FindingListResponse(
+ items=[FindingListItem(**f.model_dump(), evidence_count=counts.get(f.id, 0)) for f in page],
+ has_more=has_more,
+ next_offset=offset + limit if has_more else None,
+ )
+
+ async def attach_finding_evidence_async(
+ self, *, operation_id: UUID, finding_id: UUID, request: FindingEvidenceCreateRequest
+ ) -> FindingEvidenceAttachResponse:
+ """
+ Validate the saved owner and attach its active, viewer-addressable conversation.
+
+ Returns:
+ FindingEvidenceAttachResponse: The stable association and creation indicator.
+ """
+ operation = await self.get_async(operation_id)
+ await self._require_finding_async(operation_id=operation_id, finding_id=finding_id)
+ memory = CentralMemory.get_memory_instance()
+ attacks = await memory.get_attack_results_async(attack_result_ids=[str(request.attack_result_id)])
+ if not attacks:
+ raise FindingEvidenceNotFoundError("The owning attack no longer exists.")
+ attack = attacks[0]
+ if request.conversation_id not in attack.get_active_conversation_ids():
+ raise FindingEvidenceNotFoundError("The conversation is not an active member of this attack.")
+ stats = await memory.get_conversation_stats_async(conversation_ids=[request.conversation_id])
+ if request.conversation_id not in stats or stats[request.conversation_id].message_count == 0:
+ raise FindingEvidenceNotFoundError("The conversation has no saved messages.")
+ if attack.operation != operation.name:
+ raise FindingEvidenceAttributionError(
+ "The owning attack must have exactly the finding's saved Operation name; "
+ "missing, differently cased, or whitespace-variant attribution is not eligible."
+ )
+ evidence = FindingEvidence(
+ finding_id=finding_id, conversation_id=request.conversation_id, attack_result_id=request.attack_result_id
+ )
+ try:
+ saved = await memory.add_finding_evidence_async(evidence=evidence)
+ except LookupError as exc:
+ raise FindingNotFoundError(str(exc)) from exc
+ return FindingEvidenceAttachResponse(item=saved, created=saved.id == evidence.id)
+
+ async def list_finding_evidence_async(
+ self, *, operation_id: UUID, finding_id: UUID, limit: int, offset: int
+ ) -> FindingEvidenceListResponse:
+ """
+ Read a bounded page, retaining missing sources and propagating storage failures.
+
+ Returns:
+ FindingEvidenceListResponse: Associations with current viewer context.
+ """
+ await self.get_async(operation_id)
+ await self._require_finding_async(operation_id=operation_id, finding_id=finding_id)
+ memory = CentralMemory.get_memory_instance()
+ evidence = await memory.get_finding_evidence_async(finding_id=finding_id, limit=limit + 1, offset=offset)
+ has_more = len(evidence) > limit
+ page = evidence[:limit]
+ attacks = (
+ await memory.get_attack_results_async(attack_result_ids=list({str(item.attack_result_id) for item in page}))
+ if page
+ else []
+ )
+ owners = {attack.attack_result_id: attack for attack in attacks}
+ stats = await memory.get_conversation_stats_async(conversation_ids=[item.conversation_id for item in page])
+ items = []
+ for item in page:
+ owner = owners.get(str(item.attack_result_id))
+ available = (
+ owner is not None
+ and item.conversation_id in owner.get_active_conversation_ids()
+ and item.conversation_id in stats
+ and stats[item.conversation_id].message_count > 0
+ )
+ items.append(
+ FindingEvidenceItem(
+ item=item,
+ availability=EvidenceAvailability.AVAILABLE if available else EvidenceAvailability.UNAVAILABLE,
+ scenario_result_id=UUID(owner.attribution_parent_id)
+ if owner and owner.attribution_parent_id
+ else None,
+ )
+ )
+ return FindingEvidenceListResponse(
+ items=items, has_more=has_more, next_offset=offset + limit if has_more else None
+ )
+
+ async def detach_finding_evidence_async(self, *, operation_id: UUID, finding_id: UUID, evidence_id: UUID) -> None:
+ """Remove only the association owned by the requested finding."""
+ await self.get_async(operation_id)
+ await self._require_finding_async(operation_id=operation_id, finding_id=finding_id)
+ try:
+ await CentralMemory.get_memory_instance().delete_finding_evidence_async(
+ finding_id=finding_id, evidence_id=evidence_id
+ )
+ except LookupError as exc:
+ raise FindingEvidenceNotFoundError(str(exc)) from exc
+
+ async def _require_finding_async(self, *, operation_id: UUID, finding_id: UUID) -> Finding:
+ finding = await CentralMemory.get_memory_instance().get_finding_async(
+ operation_id=operation_id, finding_id=finding_id
+ )
+ if finding is None:
+ raise FindingNotFoundError(f"Finding '{finding_id}' not found in operation '{operation_id}'.")
+ return finding
+
+ async def update_finding_async(self, *, operation_id: UUID, finding_id: UUID, request: FindingCreate) -> Finding:
+ """
+ Replace a finding's editable fields within an existing operation.
+
+ Returns:
+ Finding: The updated finding.
+
+ Raises:
+ FindingNotFoundError: If the finding is missing or belongs to another operation.
+ """
+ await self.get_async(operation_id)
+ try:
+ return await CentralMemory.get_memory_instance().update_finding_async(
+ operation_id=operation_id, finding_id=finding_id, request=request
+ )
+ except LookupError as exc:
+ raise FindingNotFoundError(str(exc)) from exc
+
+ async def delete_finding_async(self, *, operation_id: UUID, finding_id: UUID) -> None:
+ """
+ Permanently remove a finding within an existing operation.
+
+ Raises:
+ FindingNotFoundError: If the finding is missing or belongs to another operation.
+ """
+ await self.get_async(operation_id)
+ try:
+ await CentralMemory.get_memory_instance().delete_finding_async(
+ operation_id=operation_id, finding_id=finding_id
+ )
+ except LookupError as exc:
+ raise FindingNotFoundError(str(exc)) from exc
diff --git a/pyrit/memory/alembic/versions/c8d3e5f7a901_add_operations_and_findings.py b/pyrit/memory/alembic/versions/c8d3e5f7a901_add_operations_and_findings.py
new file mode 100644
index 0000000000..24954686cc
--- /dev/null
+++ b/pyrit/memory/alembic/versions/c8d3e5f7a901_add_operations_and_findings.py
@@ -0,0 +1,83 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Add operations, their findings, and finding evidence.
+
+Creates ``OperationEntries``, ``FindingEntries``, and ``FindingEvidenceEntries``
+without changing existing tables. Downgrading drops them only while no
+operations exist.
+
+Revision ID: c8d3e5f7a901
+Revises: 901e6c7bf9d4
+Create Date: 2026-10-09 12:00:00.000000
+"""
+
+from collections.abc import Sequence # noqa: TC003
+
+import sqlalchemy as sa
+from alembic import op
+
+from pyrit.memory.memory_models import CustomUUID, UTCDateTime
+
+# revision identifiers, used by Alembic.
+revision: str = "c8d3e5f7a901"
+down_revision: str | None = "901e6c7bf9d4"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Create the operation, finding, and evidence tables."""
+ op.create_table(
+ "OperationEntries",
+ sa.Column("id", CustomUUID(), primary_key=True),
+ sa.Column("name", sa.Unicode(128), nullable=False),
+ sa.Column("name_key", sa.Unicode(384), nullable=False),
+ sa.Column("created_at", UTCDateTime(), nullable=False),
+ )
+ op.create_index("ix_OperationEntries_name_key", "OperationEntries", ["name_key"], unique=True)
+ op.create_table(
+ "FindingEntries",
+ sa.Column("id", CustomUUID(), primary_key=True),
+ sa.Column("operation_id", CustomUUID(), nullable=False),
+ sa.Column("title", sa.Unicode(), nullable=False),
+ sa.Column("description", sa.Unicode(), nullable=False),
+ sa.Column("severity", sa.String(32), nullable=False),
+ sa.Column("severity_other", sa.Unicode(), nullable=True),
+ sa.Column("harm_type", sa.Unicode(), nullable=True),
+ sa.Column("harm_type_other", sa.Unicode(), nullable=True),
+ sa.Column("created_at", UTCDateTime(), nullable=False),
+ sa.ForeignKeyConstraint(["operation_id"], ["OperationEntries.id"], name="fk_findings_operation"),
+ )
+ op.create_index("ix_FindingEntries_operation_id", "FindingEntries", ["operation_id"])
+ op.create_table(
+ "FindingEvidenceEntries",
+ sa.Column("id", CustomUUID(), primary_key=True),
+ sa.Column("finding_id", CustomUUID(), nullable=False),
+ sa.Column("conversation_id", sa.String(128), nullable=False),
+ sa.Column("attack_result_id", CustomUUID(), nullable=False),
+ sa.Column("attached_at", UTCDateTime(), nullable=False),
+ sa.ForeignKeyConstraint(["finding_id"], ["FindingEntries.id"], name="fk_finding_evidence_finding"),
+ sa.UniqueConstraint("finding_id", "conversation_id", name="uq_finding_evidence_source"),
+ )
+ op.create_index("ix_FindingEvidenceEntries_finding_id", "FindingEvidenceEntries", ["finding_id"])
+
+
+def downgrade() -> None:
+ """
+ Drop the tables only when no operations, and therefore no findings, exist.
+
+ Raises:
+ ValueError: If the downgrade is offline or operations would be lost.
+ """
+ if op.get_context().as_sql:
+ raise ValueError("Operation downgrade requires online data validation.")
+ if op.get_bind().execute(sa.text("SELECT COUNT(*) FROM OperationEntries")).scalar():
+ raise ValueError("Remove retained operations explicitly before downgrading.")
+ op.drop_index("ix_FindingEvidenceEntries_finding_id", table_name="FindingEvidenceEntries")
+ op.drop_table("FindingEvidenceEntries")
+ op.drop_index("ix_FindingEntries_operation_id", table_name="FindingEntries")
+ op.drop_table("FindingEntries")
+ op.drop_index("ix_OperationEntries_name_key", table_name="OperationEntries")
+ op.drop_table("OperationEntries")
diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py
index 63b0c0ad0b..4c70fa3283 100644
--- a/pyrit/memory/memory_interface.py
+++ b/pyrit/memory/memory_interface.py
@@ -26,6 +26,7 @@
Unicode,
and_,
case,
+ delete,
exists,
false,
func,
@@ -37,6 +38,7 @@
update,
)
from sqlalchemy import cast as sql_cast
+from sqlalchemy.engine import CursorResult
from sqlalchemy.engine.base import Engine
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
@@ -61,8 +63,11 @@
ConversationEntry,
ConverterIdentifierEntry,
EmbeddingDataEntry,
+ FindingEntry,
+ FindingEvidenceEntry,
ObservationEntry,
ObservationMessagePieceEntry,
+ OperationEntry,
PromptConverterIdentifierEntry,
PromptMemoryEntry,
ScenarioIdentifierEntry,
@@ -99,12 +104,17 @@
ConversationRetryReason,
ConversationStats,
ConverterIdentifier,
+ Finding,
+ FindingCreate,
+ FindingEvidence,
+ FindingSeverity,
IdentifierFilter,
IdentifierType,
Message,
MessagePiece,
MessageScorable,
Observation,
+ Operation,
PromptDataType,
RetryEvent,
ScenarioAttackResultDelta,
@@ -129,6 +139,7 @@
group_conversation_message_pieces_by_sequence,
sort_message_pieces,
)
+from pyrit.models.operation import operation_name_key
from pyrit.models.results.attack_result import ATTRIBUTION_FIELDS, ATTRIBUTION_VALUE_MAX_LENGTH
if TYPE_CHECKING:
@@ -552,6 +563,272 @@ async def initialize_async(self) -> None:
finally:
self._initialization_lock.release()
+ async def add_operation_async(self, operation: Operation) -> Operation:
+ """
+ Persist an operation whose trimmed, case-folded name is unique.
+
+ Returns:
+ Operation: The saved operation, or the existing one when the name is taken.
+
+ Raises:
+ IntegrityError: If the insert fails for a reason other than a taken name.
+ """
+ entry = OperationEntry(operation)
+ try:
+ async with await self.get_session_async() as session, session.begin():
+ session.add(entry)
+ await session.flush()
+ return entry.get_operation()
+ except IntegrityError:
+ existing = await self.get_operations_async(name=operation.name)
+ if not existing:
+ raise
+ return existing[0]
+
+ async def get_operations_async(
+ self, *, operation_id: uuid.UUID | None = None, name: str | None = None
+ ) -> list[Operation]:
+ """
+ Read operations ordered by name, optionally matching an ID or a name regardless of case and spacing.
+
+ Returns:
+ list[Operation]: The matching operations.
+ """
+ statement = select(OperationEntry).order_by(OperationEntry.name_key)
+ if operation_id is not None:
+ statement = statement.where(OperationEntry.id == operation_id)
+ if name is not None:
+ statement = statement.where(OperationEntry.name_key == operation_name_key(name))
+ async with await self.get_session_async() as session:
+ return [entry.get_operation() for entry in (await session.scalars(statement)).all()]
+
+ async def add_finding_async(self, finding: Finding) -> Finding:
+ """
+ Persist an assessment atomically, without changing attacks or scores.
+
+ Returns:
+ Finding: The saved assessment.
+ """
+ entry = FindingEntry(finding)
+ async with await self.get_session_async() as session, session.begin():
+ session.add(entry)
+ await session.flush()
+ return entry.get_finding()
+
+ async def update_finding_async(
+ self, *, operation_id: uuid.UUID, finding_id: uuid.UUID, request: FindingCreate
+ ) -> Finding:
+ """
+ Replace a finding's editable fields without changing identity or creation time.
+
+ Returns:
+ Finding: The updated assessment.
+
+ Raises:
+ LookupError: If the finding does not belong to the operation.
+ RuntimeError: If the database does not return a cursor result.
+ """
+ validated = FindingCreate.model_validate(request.model_dump())
+ async with await self.get_session_async() as session, session.begin():
+ result = await session.execute(
+ update(FindingEntry)
+ .where(FindingEntry.id == finding_id, FindingEntry.operation_id == operation_id)
+ .values(**validated.model_dump(mode="json"))
+ )
+ if not isinstance(result, CursorResult):
+ raise RuntimeError("Finding update did not return a database cursor result.")
+ if result.rowcount == 0:
+ raise LookupError(f"Finding '{finding_id}' not found in operation '{operation_id}'.")
+ entry = await session.get(FindingEntry, finding_id)
+ if entry is None:
+ raise LookupError(f"Finding '{finding_id}' not found in operation '{operation_id}'.")
+ return entry.get_finding()
+
+ async def delete_finding_async(self, *, operation_id: uuid.UUID, finding_id: uuid.UUID) -> None:
+ """
+ Permanently remove a finding within its operation.
+
+ Raises:
+ LookupError: If the finding does not belong to the operation.
+ RuntimeError: If the database does not return a cursor result.
+ """
+ async with await self.get_session_async() as session, session.begin():
+ owner = await session.scalar(
+ select(FindingEntry.id).where(FindingEntry.id == finding_id, FindingEntry.operation_id == operation_id)
+ )
+ if owner is None:
+ raise LookupError(f"Finding '{finding_id}' not found in operation '{operation_id}'.")
+ await session.execute(delete(FindingEvidenceEntry).where(FindingEvidenceEntry.finding_id == finding_id))
+ result = await session.execute(
+ delete(FindingEntry).where(FindingEntry.id == finding_id, FindingEntry.operation_id == operation_id)
+ )
+ if not isinstance(result, CursorResult):
+ raise RuntimeError("Finding deletion did not return a database cursor result.")
+ if result.rowcount == 0:
+ raise LookupError(f"Finding '{finding_id}' not found in operation '{operation_id}'.")
+
+ async def get_findings_async(
+ self,
+ *,
+ operation_id: uuid.UUID | None = None,
+ limit: int = 20,
+ offset: int = 0,
+ title_query: str | None = None,
+ ) -> list[Finding]:
+ """
+ Read a bounded page ordered by severity, recency, and stable identity.
+
+ Returns:
+ list[Finding]: Assessments in descending severity and recency order.
+
+ Raises:
+ ValueError: If the page bounds are invalid.
+ """
+ if limit < 1 or offset < 0:
+ raise ValueError("Finding limit must be positive and offset must be nonnegative.")
+ severity_order = case(
+ {severity.value: position for position, severity in enumerate(FindingSeverity)},
+ value=FindingEntry.severity,
+ )
+ statement = select(FindingEntry)
+ if operation_id is not None:
+ statement = statement.where(FindingEntry.operation_id == operation_id)
+ if title_query:
+ # SQL Server also treats "[" as a LIKE wildcard.
+ escaped_title = title_query.replace("/", "//").replace("%", "/%").replace("_", "/_").replace("[", "/[")
+ statement = statement.where(FindingEntry.title.ilike(f"%{escaped_title}%", escape="/"))
+ statement = (
+ statement.order_by(severity_order, FindingEntry.created_at.desc(), FindingEntry.id.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ async with await self.get_session_async() as session:
+ return [entry.get_finding() for entry in (await session.scalars(statement)).all()]
+
+ async def get_finding_async(self, *, operation_id: uuid.UUID, finding_id: uuid.UUID) -> Finding | None:
+ """
+ Read a finding only within its owning operation.
+
+ Returns:
+ Finding | None: The matching finding, if present.
+ """
+ async with await self.get_session_async() as session:
+ entry = await session.scalar(
+ select(FindingEntry).where(FindingEntry.id == finding_id, FindingEntry.operation_id == operation_id)
+ )
+ return entry.get_finding() if entry else None
+
+ async def add_finding_evidence_async(self, *, evidence: FindingEvidence) -> FindingEvidence:
+ """
+ Persist a unique association only while its finding exists.
+
+ The insert runs before the existence check so the write lock is held
+ when the finding is checked, and a concurrent delete can't leave an orphan.
+
+ Returns:
+ FindingEvidence: The saved association, or the existing one for the same finding and conversation.
+
+ Raises:
+ LookupError: If the finding does not exist.
+ IntegrityError: If the insert fails for a reason other than a duplicate source.
+ """
+ entry = FindingEvidenceEntry(evidence)
+ try:
+ async with await self.get_session_async() as session, session.begin():
+ session.add(entry)
+ await session.flush()
+ if await session.scalar(select(FindingEntry.id).where(FindingEntry.id == evidence.finding_id)) is None:
+ raise LookupError(f"Finding '{evidence.finding_id}' not found.")
+ return entry.get_evidence()
+ except IntegrityError as exc:
+ async with await self.get_session_async() as session:
+ if await session.scalar(select(FindingEntry.id).where(FindingEntry.id == evidence.finding_id)) is None:
+ raise LookupError(f"Finding '{evidence.finding_id}' not found.") from exc
+ existing = await self.get_finding_evidence_by_source_async(
+ finding_id=evidence.finding_id, conversation_id=evidence.conversation_id
+ )
+ if existing is None:
+ raise
+ return existing
+
+ async def get_finding_evidence_async(
+ self, *, finding_id: uuid.UUID, limit: int, offset: int
+ ) -> list[FindingEvidence]:
+ """
+ Read a bounded page ordered by attachment time and identity.
+
+ Returns:
+ list[FindingEvidence]: Associations in descending attachment order.
+
+ Raises:
+ ValueError: If the page bounds are invalid.
+ """
+ if limit < 1 or offset < 0:
+ raise ValueError("Evidence limit must be positive and offset must be nonnegative.")
+ statement = (
+ select(FindingEvidenceEntry)
+ .where(FindingEvidenceEntry.finding_id == finding_id)
+ .order_by(FindingEvidenceEntry.attached_at.desc(), FindingEvidenceEntry.id.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+ async with await self.get_session_async() as session:
+ return [entry.get_evidence() for entry in (await session.scalars(statement)).all()]
+
+ async def get_finding_evidence_by_source_async(
+ self, *, finding_id: uuid.UUID, conversation_id: str
+ ) -> FindingEvidence | None:
+ """
+ Read the unique association for a finding and conversation.
+
+ Returns:
+ FindingEvidence | None: The matching association, if present.
+ """
+ async with await self.get_session_async() as session:
+ entry = await session.scalar(
+ select(FindingEvidenceEntry).where(
+ FindingEvidenceEntry.finding_id == finding_id,
+ FindingEvidenceEntry.conversation_id == conversation_id,
+ )
+ )
+ return entry.get_evidence() if entry else None
+
+ async def delete_finding_evidence_async(self, *, finding_id: uuid.UUID, evidence_id: uuid.UUID) -> None:
+ """
+ Detach an association without changing its source.
+
+ Raises:
+ LookupError: If the association does not belong to the finding.
+ RuntimeError: If the database does not return a cursor result.
+ """
+ async with await self.get_session_async() as session, session.begin():
+ result = await session.execute(
+ delete(FindingEvidenceEntry).where(
+ FindingEvidenceEntry.finding_id == finding_id, FindingEvidenceEntry.id == evidence_id
+ )
+ )
+ if not isinstance(result, CursorResult):
+ raise RuntimeError("Evidence deletion did not return a database cursor result.")
+ if result.rowcount == 0:
+ raise LookupError(f"Evidence '{evidence_id}' not found in finding '{finding_id}'.")
+
+ async def get_finding_evidence_counts_async(self, *, finding_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]:
+ """
+ Count associations for a page of findings in one query.
+
+ Returns:
+ dict[uuid.UUID, int]: Counts for findings with associations.
+ """
+ if not finding_ids:
+ return {}
+ statement = (
+ select(FindingEvidenceEntry.finding_id, func.count())
+ .where(FindingEvidenceEntry.finding_id.in_(finding_ids))
+ .group_by(FindingEvidenceEntry.finding_id)
+ )
+ async with await self.get_session_async() as session:
+ return dict((await session.execute(statement)).all())
+
def _uses_legacy_memory_override(self) -> bool:
return any(
not name.startswith("_")
diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py
index 4369bff228..603ab8fee9 100644
--- a/pyrit/memory/memory_models.py
+++ b/pyrit/memory/memory_models.py
@@ -57,8 +57,11 @@
ConversationType,
ConverterIdentifier,
EvaluationIdentifier,
+ Finding,
+ FindingEvidence,
MessagePiece,
Observation,
+ Operation,
PromptDataType,
ScenarioEvaluationIdentifier,
ScenarioIdentifier,
@@ -80,6 +83,7 @@
TargetIdentifier,
scorable_from_dict,
)
+from pyrit.models.operation import operation_name_key
from pyrit.models.results.attack_result import normalize_legacy_attack_attribution
logger = logging.getLogger(__name__)
@@ -231,6 +235,129 @@ class Base(DeclarativeBase):
"""
+class OperationEntry(Base):
+ """Persistent named engagement that owns findings."""
+
+ __tablename__ = "OperationEntries"
+ __table_args__ = (Index("ix_OperationEntries_name_key", "name_key", unique=True),)
+
+ id: Mapped[uuid.UUID] = mapped_column(CustomUUID, primary_key=True)
+ name: Mapped[str] = mapped_column(Unicode(128), nullable=False)
+ # Case folding can expand a 128-unit name up to three times, for example "ΐ".
+ name_key: Mapped[str] = mapped_column(Unicode(384), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
+
+ def __init__(self, operation: Operation) -> None:
+ """Store a validated operation and its identity key."""
+ validated = Operation.model_validate(operation.model_dump())
+ self.id = validated.id
+ self.name = validated.name
+ self.name_key = operation_name_key(validated.name)
+ self.created_at = validated.created_at.astimezone(UTC)
+
+ def get_operation(self) -> Operation:
+ """
+ Reconstruct the canonical operation.
+
+ Returns:
+ Operation: The stored operation.
+ """
+ return Operation(id=self.id, name=self.name, created_at=self.created_at)
+
+
+class FindingEntry(Base):
+ """Persistent human assessment, separate from scoring and attack records."""
+
+ __tablename__ = "FindingEntries"
+ __table_args__ = (Index("ix_FindingEntries_operation_id", "operation_id"),)
+
+ id: Mapped[uuid.UUID] = mapped_column(CustomUUID, primary_key=True)
+ operation_id: Mapped[uuid.UUID] = mapped_column(
+ CustomUUID, ForeignKey(f"{OperationEntry.__tablename__}.id"), nullable=False
+ )
+ title: Mapped[str] = mapped_column(Unicode, nullable=False)
+ description: Mapped[str] = mapped_column(Unicode, nullable=False)
+ severity: Mapped[str] = mapped_column(String(32), nullable=False)
+ severity_other: Mapped[str | None] = mapped_column(Unicode, nullable=True)
+ harm_type: Mapped[str | None] = mapped_column(Unicode, nullable=True)
+ harm_type_other: Mapped[str | None] = mapped_column(Unicode, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
+
+ def __init__(self, finding: Finding) -> None:
+ """Store validated assessment values."""
+ validated = Finding.model_validate(finding.model_dump())
+ self.id = validated.id
+ self.operation_id = validated.operation_id
+ self.title = validated.title
+ self.description = validated.description
+ self.severity = validated.severity.value
+ self.severity_other = validated.severity_other
+ self.harm_type = validated.harm_type.value if validated.harm_type is not None else None
+ self.harm_type_other = validated.harm_type_other
+ self.created_at = validated.created_at.astimezone(UTC)
+
+ def get_finding(self) -> Finding:
+ """
+ Reconstruct the canonical assessment.
+
+ Returns:
+ Finding: The stored assessment.
+ """
+ return Finding(
+ id=self.id,
+ operation_id=self.operation_id,
+ title=self.title,
+ description=self.description,
+ severity=self.severity,
+ severity_other=self.severity_other,
+ harm_type=self.harm_type,
+ harm_type_other=self.harm_type_other,
+ created_at=self.created_at,
+ )
+
+
+class FindingEvidenceEntry(Base):
+ """Persistent association without foreign keys to removable sources."""
+
+ __tablename__ = "FindingEvidenceEntries"
+ __table_args__ = (
+ Index("ix_FindingEvidenceEntries_finding_id", "finding_id"),
+ UniqueConstraint("finding_id", "conversation_id", name="uq_finding_evidence_source"),
+ )
+
+ id: Mapped[uuid.UUID] = mapped_column(CustomUUID, primary_key=True)
+ finding_id: Mapped[uuid.UUID] = mapped_column(
+ CustomUUID, ForeignKey(f"{FindingEntry.__tablename__}.id"), nullable=False
+ )
+ conversation_id: Mapped[str] = mapped_column(String(128), nullable=False)
+ attack_result_id: Mapped[uuid.UUID] = mapped_column(CustomUUID, nullable=False)
+ attached_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
+
+ def __init__(self, evidence: FindingEvidence) -> None:
+ """Store validated association values."""
+ validated = FindingEvidence.model_validate(evidence.model_dump())
+ self.id = validated.id
+ self.finding_id = validated.finding_id
+ self.conversation_id = validated.conversation_id
+ self.attack_result_id = validated.attack_result_id
+ self.attached_at = validated.attached_at.astimezone(UTC)
+
+ def get_evidence(self) -> FindingEvidence:
+ """
+ Reconstruct the stored association.
+
+ Returns:
+ FindingEvidence: The canonical association.
+ """
+ return FindingEvidence(
+ id=self.id,
+ finding_id=self.finding_id,
+ conversation_id=self.conversation_id,
+ attack_result_id=self.attack_result_id,
+ attached_at=self.attached_at,
+ )
+
+
class PromptMemoryEntry(Base):
"""
Represents the prompt data.
diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py
index 3cfc87e1aa..a6ff41d471 100644
--- a/pyrit/models/__init__.py
+++ b/pyrit/models/__init__.py
@@ -60,6 +60,7 @@
)
from pyrit.models.conversation_stats import ConversationStats
from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation
+ from pyrit.models.finding import Finding, FindingCreate, FindingEvidence, FindingSeverity
from pyrit.models.harm_definition import HarmDefinition, ScaleDescription, get_all_harm_definitions
from pyrit.models.identifiers import (
REGISTRY_NAME_PATTERN,
@@ -120,6 +121,7 @@
)
from pyrit.models.messages.conversation_reference import ConversationReference, ConversationType
from pyrit.models.messages.conversation_retry import ConversationRetry, ConversationRetryReason
+ from pyrit.models.operation import Operation, OperationCreate
from pyrit.models.parameter import (
ComponentType,
Parameter,
@@ -322,6 +324,10 @@
"ConversationRetry": "pyrit.models.messages.conversation_retry",
"ConversationRetryReason": "pyrit.models.messages.conversation_retry",
"ConversationStats": "pyrit.models.conversation_stats",
+ "Finding": "pyrit.models.finding",
+ "FindingCreate": "pyrit.models.finding",
+ "FindingEvidence": "pyrit.models.finding",
+ "FindingSeverity": "pyrit.models.finding",
"ConversationType": "pyrit.models.messages.conversation_reference",
"ContentEntryScorable": "pyrit.models.score",
"ContentScorable": "pyrit.models.score",
@@ -365,6 +371,8 @@
"Observation": "pyrit.models.score",
"ObservationPayload": "pyrit.models.score",
"ObjectiveTargetEvaluationIdentifier": "pyrit.models.identifiers",
+ "Operation": "pyrit.models.operation",
+ "OperationCreate": "pyrit.models.operation",
"Parameter": "pyrit.models.parameter",
"ParameterDestination": "pyrit.models.parameter",
"PromptDataType": "pyrit.models.literals",
diff --git a/pyrit/models/finding.py b/pyrit/models/finding.py
new file mode 100644
index 0000000000..a0387aefc4
--- /dev/null
+++ b/pyrit/models/finding.py
@@ -0,0 +1,93 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from datetime import UTC, datetime
+from enum import Enum
+from typing import Self
+from uuid import UUID, uuid4
+
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
+
+from pyrit.models.harm_category import HarmCategory
+
+
+class FindingSeverity(str, Enum):
+ """Operator-selected severity, ordered from highest to lowest."""
+
+ CRITICAL = "critical"
+ IMPORTANT = "important"
+ MODERATE = "moderate"
+ LOW = "low"
+ INFORMATIONAL = "informational"
+ OTHER = "other"
+
+
+class FindingCreate(BaseModel):
+ """A human assessment independent of attack outcomes and scores."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ title: str
+ description: str = ""
+ severity: FindingSeverity
+ severity_other: str | None = None
+ harm_type: HarmCategory | None = None
+ harm_type_other: str | None = None
+
+ @model_validator(mode="after")
+ def _validate_classifications(self) -> Self:
+ """
+ Require custom text only for an Other selection.
+
+ Returns:
+ Self: The validated assessment.
+
+ Raises:
+ ValueError: If custom text is missing, blank, or has no Other selection.
+ """
+ for field, is_other, text in [
+ ("severity", self.severity == FindingSeverity.OTHER, self.severity_other),
+ ("harm_type", self.harm_type == HarmCategory.OTHER, self.harm_type_other),
+ ]:
+ if is_other:
+ if text is None or not text.strip():
+ raise ValueError(f"{field}_other must not be blank when {field} is Other.")
+ elif text is not None:
+ raise ValueError(f"{field}_other is only allowed when {field} is Other.")
+ return self
+
+ @field_validator("title")
+ @classmethod
+ def _reject_blank(cls, value: str) -> str:
+ """
+ Reject a blank title without normalizing its contents.
+
+ Returns:
+ str: The original value.
+
+ Raises:
+ ValueError: If the value is blank.
+ """
+ if not value.strip():
+ raise ValueError("Must not be blank.")
+ return value
+
+
+class Finding(FindingCreate):
+ """A saved assessment belonging to one operation."""
+
+ operation_id: UUID
+ id: UUID = Field(default_factory=uuid4)
+ created_at: AwareDatetime = Field(default_factory=lambda: datetime.now(UTC))
+
+
+class FindingEvidence(BaseModel):
+ """A live conversation reference supporting a saved assessment."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: UUID = Field(default_factory=uuid4)
+ finding_id: UUID
+ conversation_id: str = Field(min_length=1, max_length=128)
+ attack_result_id: UUID
+ attached_at: AwareDatetime = Field(default_factory=lambda: datetime.now(UTC))
diff --git a/pyrit/models/operation.py b/pyrit/models/operation.py
new file mode 100644
index 0000000000..5baf8de1ce
--- /dev/null
+++ b/pyrit/models/operation.py
@@ -0,0 +1,51 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from datetime import UTC, datetime
+from uuid import UUID, uuid4
+
+from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator
+
+
+def operation_name_key(name: str) -> str:
+ """
+ Return the identity key that makes names unique regardless of case and surrounding whitespace.
+
+ Returns:
+ str: The trimmed, case-folded name.
+ """
+ return name.strip().casefold()
+
+
+class OperationCreate(BaseModel):
+ """A named engagement that groups human findings."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ name: str
+
+ @field_validator("name")
+ @classmethod
+ def _normalize_name(cls, value: str) -> str:
+ """
+ Trim surrounding whitespace and enforce the SQL Server NVARCHAR(128) bound.
+
+ Returns:
+ str: The trimmed display name.
+
+ Raises:
+ ValueError: If the trimmed name is blank or exceeds 128 UTF-16 code units.
+ """
+ value = value.strip()
+ if not value:
+ raise ValueError("Must not be blank.")
+ if len(value.encode("utf-16-le")) // 2 > 128:
+ raise ValueError("Must be at most 128 UTF-16 code units.")
+ return value
+
+
+class Operation(OperationCreate):
+ """A saved operation with stable identity and creation time."""
+
+ id: UUID = Field(default_factory=uuid4)
+ created_at: AwareDatetime = Field(default_factory=lambda: datetime.now(UTC))
diff --git a/tests/unit/backend/test_operations.py b/tests/unit/backend/test_operations.py
new file mode 100644
index 0000000000..087a741f22
--- /dev/null
+++ b/tests/unit/backend/test_operations.py
@@ -0,0 +1,496 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+from uuid import UUID, uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlalchemy import delete
+from sqlalchemy.exc import IntegrityError
+
+from pyrit.backend.main import app
+from pyrit.backend.models.operations import FindingEvidenceCreateRequest
+from pyrit.backend.services.operation_service import FindingNotFoundError, OperationService
+from pyrit.memory import CentralMemory, SQLiteMemory
+from pyrit.memory.memory_models import AttackResultEntry, Base, PromptMemoryEntry
+from pyrit.models import (
+ AttackResult,
+ ConversationReference,
+ ConversationType,
+ Finding,
+ FindingEvidence,
+ Message,
+ MessagePiece,
+ Operation,
+)
+from pyrit.models.harm_category import HarmCategory
+
+pytestmark = pytest.mark.usefixtures("patch_central_database")
+
+MISSING_ID = UUID(int=404)
+
+
+def test_operations_create_list_and_get(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ created = client.post("/api/operations", json={"name": " Red team / α% "})
+ assert created.status_code == 201
+ operation = created.json()
+ assert operation["name"] == "Red team / α%"
+ client.post("/api/operations", json={"name": "Alpha"})
+ assert [item["name"] for item in client.get("/api/operations").json()["items"]] == ["Alpha", "Red team / α%"]
+ assert client.get(f"/api/operations/{operation['id']}").json() == operation
+ assert client.get(f"/api/operations/{MISSING_ID}").status_code == 404
+
+
+def test_duplicate_operation_conflicts_with_existing_record(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ existing = client.post("/api/operations", json={"name": "Operation A"}).json()
+ conflict = client.post("/api/operations", json={"name": " operation a "})
+ assert conflict.status_code == 409
+ assert conflict.json()["detail"]["operation"] == existing
+ assert len(client.get("/api/operations").json()["items"]) == 1
+
+
+def test_findings_are_created_and_listed_under_an_operation(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Case"}).json()["id"]
+ other_id = client.post("/api/operations", json={"name": "Other"}).json()["id"]
+ created = client.post(
+ f"/api/operations/{operation_id}/findings", json={"title": "Assessment", "severity": "informational"}
+ )
+ assert created.status_code == 201
+ finding = created.json()
+ assert finding["operation_id"] == operation_id
+ assert finding["description"] == ""
+ client.post(f"/api/operations/{other_id}/findings", json={"title": "Elsewhere", "severity": "low"})
+ listed = client.get(f"/api/operations/{operation_id}/findings", params={"limit": 1}).json()
+ assert listed == {"items": [{**finding, "evidence_count": 0}], "has_more": False, "next_offset": None}
+
+
+def test_finding_options_exposes_canonical_harm_types(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ response = client.get("/api/operations/finding-options")
+ assert response.status_code == 200
+ assert response.json() == {"harm_types": [category.value for category in HarmCategory]}
+ assert response.json()["harm_types"].count("Other") == 1
+ assert TestClient(app).get("/api/operations/finding-options").status_code != 200
+
+
+def test_finding_api_round_trips_optional_and_custom_classifications(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Classifications"}).json()["id"]
+ path = f"/api/operations/{operation_id}/findings"
+ body = {
+ "title": "Custom",
+ "severity": "other",
+ "severity_other": " Team severity ",
+ "harm_type": "Other",
+ "harm_type_other": " Team harm ",
+ }
+ response = client.post(path, json=body)
+ assert response.status_code == 201
+ finding = response.json()
+ assert {key: finding[key] for key in body} == body
+ assert client.get(path).json()["items"] == [{**finding, "evidence_count": 0}]
+ updated = client.put(f"{path}/{finding['id']}", json={"title": "Edited", "severity": "low", "harm_type": "Malware"})
+ assert updated.status_code == 200
+ assert updated.json()["harm_type"] == "Malware"
+ assert updated.json()["severity_other"] is None
+ assert updated.json()["harm_type_other"] is None
+ assert updated.json()["id"] == finding["id"]
+ assert updated.json()["created_at"] == finding["created_at"]
+ for invalid in [
+ {"severity": "other"},
+ {"severity": "low", "severity_other": "Stray"},
+ {"severity": "low", "harm_type": "Other"},
+ {"severity": "low", "harm_type": "Unknown"},
+ ]:
+ assert client.post(path, json={"title": "Invalid", **invalid}).status_code == 422
+ assert client.put(f"{path}/{finding['id']}", json={"title": "Invalid", **invalid}).status_code == 422
+ cleared = client.put(f"{path}/{finding['id']}", json={"title": "No harm type", "severity": "moderate"})
+ assert cleared.json()["harm_type"] is None
+
+
+def test_findings_require_an_existing_operation(compatibility_headers: dict[str, str]) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ body = {"title": "Assessment", "severity": "low"}
+ assert client.post(f"/api/operations/{MISSING_ID}/findings", json=body).status_code == 404
+ assert client.get(f"/api/operations/{MISSING_ID}/findings").status_code == 404
+
+
+@pytest.mark.parametrize(
+ "path,body",
+ [
+ ("/api/operations", {"name": " "}),
+ ("/api/operations", {"name": "😀" * 65}),
+ ("findings", {"title": "", "severity": "low"}),
+ ("findings", {"title": "Assessment", "severity": "urgent"}),
+ ("findings", {"title": "Assessment", "severity": "low", "operation": "Case"}),
+ ],
+)
+def test_operations_and_findings_reject_invalid_input(
+ path: str, body: dict[str, str], compatibility_headers: dict[str, str]
+) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Case"}).json()["id"]
+ url = f"/api/operations/{operation_id}/findings" if path == "findings" else path
+ assert client.post(url, json=body).status_code == 422
+ assert client.get(f"/api/operations/{operation_id}/findings").json()["items"] == []
+
+
+def test_operation_findings_validate_pagination_and_require_compatibility(
+ compatibility_headers: dict[str, str],
+) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Case"}).json()["id"]
+ for params in [{"offset": -1}, {"limit": 0}, {"limit": 101}]:
+ assert client.get(f"/api/operations/{operation_id}/findings", params=params).status_code == 422
+ assert TestClient(app).get("/api/operations").status_code != 200
+
+
+def test_finding_update_and_delete_preserve_identity_and_operation(
+ compatibility_headers: dict[str, str],
+) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation = client.post("/api/operations", json={"name": "Case"}).json()
+ path = f"/api/operations/{operation['id']}/findings"
+ original = client.post(path, json={"title": "Original", "severity": "low"}).json()
+ finding_path = f"{path}/{original['id']}"
+ edited = client.put(finding_path, json={"title": "Edited", "severity": "critical", "description": "Notes"})
+ assert edited.status_code == 200
+ assert edited.json() == {**original, "title": "Edited", "severity": "critical", "description": "Notes"}
+ assert client.get(path).json()["items"] == [{**edited.json(), "evidence_count": 0}]
+ deleted = client.delete(finding_path)
+ assert deleted.status_code == 204
+ assert deleted.content == b""
+ assert client.get(path).json()["items"] == []
+ assert client.get(f"/api/operations/{operation['id']}").json() == operation
+ assert client.delete(finding_path).status_code == 404
+ assert client.put(finding_path, json={"title": "Missing", "severity": "low"}).status_code == 404
+
+
+def test_finding_mutations_reject_wrong_operations_and_invalid_fields(
+ compatibility_headers: dict[str, str],
+) -> None:
+ client = TestClient(app, headers=compatibility_headers)
+ operation_id = client.post("/api/operations", json={"name": "Case"}).json()["id"]
+ other_id = client.post("/api/operations", json={"name": "Other"}).json()["id"]
+ path = f"/api/operations/{operation_id}/findings"
+ original = client.post(path, json={"title": "Original", "severity": "low"}).json()
+ body = {"title": "Edited", "severity": "critical"}
+ for requested_id in [other_id, str(MISSING_ID)]:
+ wrong_path = f"/api/operations/{requested_id}/findings/{original['id']}"
+ assert client.put(wrong_path, json=body).status_code == 404
+ assert client.delete(wrong_path).status_code == 404
+ finding_path = f"{path}/{original['id']}"
+ for invalid in [
+ {**body, "title": " "},
+ {**body, "severity": "urgent"},
+ {**body, "operation_id": other_id},
+ {**body, "created_at": original["created_at"]},
+ ]:
+ assert client.put(finding_path, json=invalid).status_code == 422
+ assert TestClient(app).put(finding_path, json=body).status_code != 200
+ assert TestClient(app).delete(finding_path).status_code != 204
+ assert client.get(path).json()["items"] == [{**original, "evidence_count": 0}]
+
+
+@pytest.fixture
+async def evidence_source_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, Finding, AttackResult]:
+ return await _store_evidence_source_async(sqlite_instance)
+
+
+async def _store_evidence_source_async(sqlite_instance: SQLiteMemory) -> tuple[Operation, Finding, AttackResult]:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Exact Operation"))
+ finding = await sqlite_instance.add_finding_async(
+ Finding(operation_id=operation.id, title="100%_Proof", severity="low")
+ )
+ attack = AttackResult(conversation_id="main", objective="Evidence", operation=operation.name)
+ await sqlite_instance.add_attack_results_to_memory_async(attack_results=[attack])
+ await sqlite_instance.add_message_to_memory_async(
+ request=Message(message_pieces=[MessagePiece(role="user", original_value="Evidence", conversation_id="main")])
+ )
+ return operation, finding, attack
+
+
+def test_finding_evidence_attach_returns_created_then_already_attached(
+ compatibility_headers: dict[str, str], evidence_source_async: tuple[Operation, Finding, AttackResult]
+) -> None:
+ operation, finding, attack = evidence_source_async
+ client = TestClient(app, headers=compatibility_headers)
+ path = f"/api/operations/{operation.id}/findings/{finding.id}/evidence"
+ body = {"attack_result_id": attack.attack_result_id, "conversation_id": "main"}
+ created = client.post(path, json=body)
+ repeated = client.post(path, json=body)
+ assert created.status_code == 201
+ assert repeated.status_code == 200
+ assert created.json()["created"] is True and repeated.json()["created"] is False
+ assert created.json()["item"] == repeated.json()["item"]
+ page = client.get(path, params={"limit": 1}).json()
+ assert page["items"][0] == {"item": created.json()["item"], "availability": "available", "scenario_result_id": None}
+ assert client.get(f"/api/operations/{operation.id}/findings").json()["items"][0]["evidence_count"] == 1
+ evidence_id = created.json()["item"]["id"]
+ assert client.delete(f"{path}/{evidence_id}").status_code == 204
+ assert client.delete(f"{path}/{evidence_id}").status_code == 404
+
+
+@pytest.mark.parametrize("attribution", [None, "Other", "exact Operation", "Exact Operation "])
+async def test_finding_evidence_rejects_attribution_async(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+ attribution: str | None,
+) -> None:
+ operation, finding, attack = evidence_source_async
+ await sqlite_instance.update_attack_result_by_id_async(
+ attack_result_id=attack.attack_result_id, update_fields={"operation": attribution}
+ )
+ response = TestClient(app, headers=compatibility_headers).post(
+ f"/api/operations/{operation.id}/findings/{finding.id}/evidence",
+ json={"attack_result_id": attack.attack_result_id, "conversation_id": "main"},
+ )
+ assert response.status_code == 409
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[finding.id]) == {}
+
+
+@pytest.mark.parametrize("missing", ["operation", "finding", "attack", "conversation"])
+def test_finding_evidence_rejects_missing_sources(
+ compatibility_headers: dict[str, str],
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+ missing: str,
+) -> None:
+ operation, finding, attack = evidence_source_async
+ response = TestClient(app, headers=compatibility_headers).post(
+ f"/api/operations/{MISSING_ID if missing == 'operation' else operation.id}/findings/"
+ f"{MISSING_ID if missing == 'finding' else finding.id}/evidence",
+ json={
+ "attack_result_id": str(MISSING_ID) if missing == "attack" else attack.attack_result_id,
+ "conversation_id": "other" if missing == "conversation" else "main",
+ },
+ )
+ assert response.status_code == 404
+
+
+async def test_finding_evidence_related_membership_and_unavailability_async(
+ sqlite_instance: SQLiteMemory,
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ attack.related_conversations = {
+ ConversationReference(conversation_id="related", conversation_type=ConversationType.PRUNED),
+ }
+ await sqlite_instance.update_attack_result_by_id_async(
+ attack_result_id=attack.attack_result_id, update_fields={"pruned_conversation_ids": ["related"]}
+ )
+ await sqlite_instance.add_message_to_memory_async(
+ request=Message(message_pieces=[MessagePiece(role="user", original_value="Related", conversation_id="related")])
+ )
+ service = OperationService()
+ saved = await service.attach_finding_evidence_async(
+ operation_id=operation.id,
+ finding_id=finding.id,
+ request=FindingEvidenceCreateRequest(attack_result_id=attack.attack_result_id, conversation_id="related"),
+ )
+ await sqlite_instance.update_attack_result_by_id_async(
+ attack_result_id=attack.attack_result_id, update_fields={"pruned_conversation_ids": []}
+ )
+ page = await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=20, offset=0
+ )
+ assert page.items[0].item == saved.item
+ assert page.items[0].availability == "unavailable"
+ with patch.object(sqlite_instance, "get_attack_results_async", new_callable=AsyncMock) as mocked:
+ mocked.side_effect = RuntimeError("storage failed")
+ with pytest.raises(RuntimeError, match="storage failed"):
+ await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=20, offset=0
+ )
+
+
+async def test_finding_search_is_literal_case_insensitive_async(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Search"))
+ for title in ["100%_Proof", "100XXProof", "other"]:
+ await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title=title, severity="low"))
+ page = await OperationService().list_findings_async(
+ operation_id=operation.id, limit=20, offset=0, title_query="%_pROOF"
+ )
+ assert [f.title for f in page.items] == ["100%_Proof"]
+ assert page.items[0].evidence_count == 0
+
+
+async def test_concurrent_attach_preserves_one_association_async(
+ tmp_path: Path,
+) -> None:
+ memory = SQLiteMemory.__new__(SQLiteMemory)
+ with patch.object(memory, "cleanup"):
+ memory.__init__(db_path=tmp_path / "evidence.db", skip_schema_migration=True)
+ try:
+ await asyncio.to_thread(Base.metadata.create_all, memory.engine)
+ with patch.object(CentralMemory, "get_memory_instance", return_value=memory):
+ operation, finding, attack = await _store_evidence_source_async(memory)
+ request = FindingEvidenceCreateRequest(attack_result_id=attack.attack_result_id, conversation_id="main")
+ results = await asyncio.gather(
+ *[
+ OperationService().attach_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, request=request
+ )
+ for _ in range(2)
+ ]
+ )
+ assert sorted(r.created for r in results) == [False, True]
+ assert results[0].item == results[1].item
+ assert await memory.get_finding_evidence_counts_async(finding_ids=[finding.id]) == {finding.id: 1}
+ finally:
+ await memory.dispose_engine_async()
+
+
+async def test_unrelated_integrity_failure_propagates_async(
+ sqlite_instance: SQLiteMemory,
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ service = OperationService()
+ request = FindingEvidenceCreateRequest(attack_result_id=attack.attack_result_id, conversation_id="main")
+ await service.attach_finding_evidence_async(operation_id=operation.id, finding_id=finding.id, request=request)
+ failure = IntegrityError("insert", {}, RuntimeError("UNIQUE constraint failed: FindingEvidenceEntries.id"))
+ with patch.object(sqlite_instance, "add_finding_evidence_async", side_effect=failure):
+ with pytest.raises(IntegrityError) as raised:
+ await service.attach_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, request=request
+ )
+ assert raised.value is failure
+
+
+async def test_attach_after_concurrent_finding_delete_is_not_found_async(
+ sqlite_instance: SQLiteMemory,
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ add_evidence = sqlite_instance.add_finding_evidence_async
+
+ async def delete_then_add_async(*, evidence: FindingEvidence) -> FindingEvidence:
+ await sqlite_instance.delete_finding_async(operation_id=operation.id, finding_id=finding.id)
+ return await add_evidence(evidence=evidence)
+
+ with patch.object(sqlite_instance, "add_finding_evidence_async", side_effect=delete_then_add_async):
+ with pytest.raises(FindingNotFoundError):
+ await OperationService().attach_finding_evidence_async(
+ operation_id=operation.id,
+ finding_id=finding.id,
+ request=FindingEvidenceCreateRequest(attack_result_id=attack.attack_result_id, conversation_id="main"),
+ )
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[finding.id]) == {}
+
+
+@pytest.mark.parametrize("table", [AttackResultEntry, PromptMemoryEntry])
+async def test_evidence_retains_missing_source_async(
+ sqlite_instance: SQLiteMemory,
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+ table: type[Base],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ service = OperationService()
+ saved = await service.attach_finding_evidence_async(
+ operation_id=operation.id,
+ finding_id=finding.id,
+ request=FindingEvidenceCreateRequest(attack_result_id=attack.attack_result_id, conversation_id="main"),
+ )
+ async with await sqlite_instance.get_session_async() as session, session.begin():
+ await session.execute(delete(table))
+ page = await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=1, offset=0
+ )
+ assert page.items[0].item == saved.item
+ assert page.items[0].availability == "unavailable"
+
+
+async def test_evidence_detach_is_scoped_and_does_not_mutate_source_async(
+ sqlite_instance: SQLiteMemory,
+ compatibility_headers: dict[str, str],
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ client = TestClient(app, headers=compatibility_headers)
+ saved = client.post(
+ f"/api/operations/{operation.id}/findings/{finding.id}/evidence",
+ json={
+ "attack_result_id": attack.attack_result_id,
+ "conversation_id": "main",
+ },
+ ).json()["item"]
+ other = await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="Other", severity="low"))
+ assert (
+ client.delete(f"/api/operations/{operation.id}/findings/{other.id}/evidence/{saved['id']}").status_code == 404
+ )
+ assert client.delete(f"/api/operations/{uuid4()}/findings/{finding.id}/evidence/{saved['id']}").status_code == 404
+ assert (
+ client.delete(f"/api/operations/{operation.id}/findings/{finding.id}/evidence/{saved['id']}").status_code == 204
+ )
+ assert (await sqlite_instance.get_attack_results_async(attack_result_ids=[attack.attack_result_id]))[0] == attack
+ assert (await sqlite_instance.get_conversation_stats_async(conversation_ids=["main"]))["main"].message_count == 1
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ {"attack_result_id": "invalid", "conversation_id": "main"},
+ {"attack_result_id": str(MISSING_ID), "conversation_id": ""},
+ {"attack_result_id": str(MISSING_ID), "conversation_id": "x" * 129},
+ {"attack_result_id": str(MISSING_ID), "conversation_id": "main", "operation": "forged"},
+ ],
+)
+def test_evidence_input_is_strict(
+ compatibility_headers: dict[str, str],
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+ body: dict[str, str],
+) -> None:
+ operation, finding, _ = evidence_source_async
+ client = TestClient(app, headers=compatibility_headers)
+ assert client.post(f"/api/operations/{operation.id}/findings/{finding.id}/evidence", json=body).status_code == 422
+
+
+async def test_evidence_page_batches_sources_and_counts_async(
+ sqlite_instance: SQLiteMemory,
+ evidence_source_async: tuple[Operation, Finding, AttackResult],
+) -> None:
+ operation, finding, attack = evidence_source_async
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(
+ finding_id=finding.id,
+ attack_result_id=UUID(attack.attack_result_id),
+ conversation_id="main",
+ )
+ )
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(
+ finding_id=finding.id,
+ attack_result_id=uuid4(),
+ conversation_id="missing",
+ )
+ )
+ with (
+ patch.object(
+ sqlite_instance, "get_attack_results_async", wraps=sqlite_instance.get_attack_results_async
+ ) as owners,
+ patch.object(
+ sqlite_instance, "get_conversation_stats_async", wraps=sqlite_instance.get_conversation_stats_async
+ ) as stats,
+ patch.object(
+ sqlite_instance,
+ "get_finding_evidence_counts_async",
+ wraps=sqlite_instance.get_finding_evidence_counts_async,
+ ) as counts,
+ ):
+ service = OperationService()
+ page = await service.list_finding_evidence_async(
+ operation_id=operation.id, finding_id=finding.id, limit=1, offset=0
+ )
+ assert page.has_more and page.next_offset == 1
+ owners.assert_called_once()
+ stats.assert_called_once()
+ await service.list_findings_async(operation_id=operation.id, limit=20, offset=0)
+ counts.assert_called_once()
+ assert counts.call_args.kwargs["finding_ids"] == [finding.id]
diff --git a/tests/unit/memory/test_analytics_migration.py b/tests/unit/memory/test_analytics_migration.py
index 5f48352ab5..c26b672dcd 100644
--- a/tests/unit/memory/test_analytics_migration.py
+++ b/tests/unit/memory/test_analytics_migration.py
@@ -35,7 +35,7 @@
def test_analytics_migration_is_single_successor_of_main() -> None:
scripts = ScriptDirectory(str(Path(__file__).resolve().parents[3] / "pyrit" / "memory" / "alembic"))
- assert scripts.get_heads() == [ANALYTICS_REVISION]
+ assert len(scripts.get_heads()) == 1
revision = scripts.get_revision(ANALYTICS_REVISION)
assert revision is not None
assert revision.down_revision == MAIN_REVISION
@@ -80,7 +80,7 @@ def test_analytics_migration_upgrades_from_main_revisions(*, tmp_path: Path, sta
head_revision = connection.execute(
text("SELECT version_num FROM pyrit_memory_alembic_version")
).scalar_one()
- assert head_revision == ANALYTICS_REVISION
+ assert head_revision == ScriptDirectory.from_config(config).get_current_head()
assert (
connection.execute(
text('SELECT resolved_atomic_attack_identifier_hash FROM "AttackResultEntries" WHERE id = :id'),
diff --git a/tests/unit/memory/test_findings.py b/tests/unit/memory/test_findings.py
new file mode 100644
index 0000000000..1d037dbaf9
--- /dev/null
+++ b/tests/unit/memory/test_findings.py
@@ -0,0 +1,315 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from datetime import UTC, datetime
+from unittest.mock import MagicMock, patch
+from uuid import UUID, uuid4
+
+import pytest
+from sqlalchemy import func, select
+from sqlalchemy.dialects import mssql
+from sqlalchemy.engine import ScalarResult
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from pyrit.memory import SQLiteMemory
+from pyrit.memory.memory_models import (
+ AttackResultEntry,
+ FindingEntry,
+ FindingEvidenceEntry,
+ PromptMemoryEntry,
+ ScoreEntry,
+)
+from pyrit.models import Finding, FindingCreate, FindingEvidence, Operation
+
+pytestmark = pytest.mark.usefixtures("patch_central_database")
+
+
+async def test_operations_are_unique_by_trimmed_case_folded_name(sqlite_instance: SQLiteMemory) -> None:
+ saved = await sqlite_instance.add_operation_async(Operation(name=" Operation A "))
+ await sqlite_instance.add_operation_async(Operation(name="Beta"))
+ assert await sqlite_instance.add_operation_async(Operation(name="operation a")) == saved
+ assert [operation.name for operation in await sqlite_instance.get_operations_async()] == ["Beta", "Operation A"]
+ assert await sqlite_instance.get_operations_async(name=" OPERATION a ") == [saved]
+ assert await sqlite_instance.get_operations_async(operation_id=saved.id) == [saved]
+
+
+async def test_findings_paginate_within_one_operation(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Red team / α%"))
+ other = await sqlite_instance.add_operation_async(Operation(name="Elsewhere"))
+ stamp = datetime(2026, 10, 6, tzinfo=UTC)
+ for number, severity in [(1, "low"), (2, "critical"), (3, "critical"), (4, "informational")]:
+ await sqlite_instance.add_finding_async(
+ Finding(
+ id=UUID(int=number), created_at=stamp, operation_id=operation.id, title=str(number), severity=severity
+ )
+ )
+ await sqlite_instance.add_finding_async(Finding(operation_id=other.id, title="Other", severity="critical"))
+ first = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=2)
+ second = await sqlite_instance.get_findings_async(operation_id=operation.id, limit=2, offset=2)
+ assert [finding.title for finding in first + second] == ["3", "2", "1", "4"]
+ async with await sqlite_instance.get_session_async() as session:
+ for table in [AttackResultEntry, PromptMemoryEntry, ScoreEntry]:
+ assert await session.scalar(select(func.count()).select_from(table)) == 0
+
+
+async def test_duplicate_id_rolls_back_without_losing_saved_finding(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Case"))
+ finding = Finding(operation_id=operation.id, title="Original", severity="low")
+ assert await sqlite_instance.add_finding_async(finding) == finding
+ with pytest.raises(IntegrityError):
+ await sqlite_instance.add_finding_async(finding.model_copy(update={"title": "Duplicate"}))
+ assert [f.title for f in await sqlite_instance.get_findings_async()] == ["Original"]
+ async with await sqlite_instance.get_session_async() as session:
+ assert await session.scalar(select(func.count()).select_from(FindingEntry)) == 1
+
+
+async def test_update_finding_preserves_identity_and_reorders_severity(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Case"))
+ original = await sqlite_instance.add_finding_async(
+ Finding(operation_id=operation.id, title="Original", severity="low")
+ )
+ await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="Other", severity="moderate"))
+ updated = await sqlite_instance.update_finding_async(
+ operation_id=operation.id,
+ finding_id=original.id,
+ request=FindingCreate(title="Edited", severity="critical", description="Notes"),
+ )
+ assert updated.id == original.id
+ assert updated.operation_id == original.operation_id
+ assert updated.created_at == original.created_at
+ assert updated.title == "Edited"
+ assert updated.description == "Notes"
+ assert [finding.title for finding in await sqlite_instance.get_findings_async()] == ["Edited", "Other"]
+
+
+async def test_finding_classifications_persist_and_clear_without_losing_evidence(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Classifications"))
+ original = Finding.model_validate(
+ {
+ "operation_id": operation.id,
+ "title": "Custom",
+ "severity": "other",
+ "severity_other": " Review needed ",
+ "harm_type": "Other",
+ "harm_type_other": " Team harm ",
+ }
+ )
+ saved = await sqlite_instance.add_finding_async(original)
+ assert saved == original
+ evidence = FindingEvidence(finding_id=saved.id, conversation_id="preserved", attack_result_id=uuid4())
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ read = await sqlite_instance.get_finding_async(operation_id=operation.id, finding_id=saved.id)
+ assert read == original
+ updated = await sqlite_instance.update_finding_async(
+ operation_id=operation.id,
+ finding_id=saved.id,
+ request=FindingCreate(title="Canonical", severity="important", harm_type="Malware"),
+ )
+ assert updated.id == saved.id
+ assert updated.created_at == saved.created_at
+ assert updated.severity_other is None
+ assert updated.harm_type.value == "Malware"
+ assert updated.harm_type_other is None
+ cleared = await sqlite_instance.update_finding_async(
+ operation_id=operation.id, finding_id=saved.id, request=FindingCreate(title="Optional", severity="low")
+ )
+ assert cleared.harm_type is None
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=saved.id, limit=20, offset=0) == [evidence]
+
+
+async def test_custom_severity_sorts_after_every_preset(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Order"))
+ stamp = datetime(2026, 10, 9, tzinfo=UTC)
+ for number, severity in enumerate(["other", "informational", "low", "moderate", "important", "critical"], start=1):
+ await sqlite_instance.add_finding_async(
+ Finding.model_validate(
+ {
+ "id": UUID(int=number),
+ "created_at": stamp,
+ "operation_id": operation.id,
+ "title": severity,
+ "severity": severity,
+ "severity_other": "Critical" if severity == "other" else None,
+ }
+ )
+ )
+ assert [finding.title for finding in await sqlite_instance.get_findings_async()] == [
+ "critical",
+ "important",
+ "moderate",
+ "low",
+ "informational",
+ "other",
+ ]
+
+
+async def test_finding_mutations_are_scoped_and_delete_only_the_finding(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Case"))
+ other = await sqlite_instance.add_operation_async(Operation(name="Other"))
+ original = await sqlite_instance.add_finding_async(
+ Finding(operation_id=operation.id, title="Original", severity="low")
+ )
+ with pytest.raises(LookupError, match="not found"):
+ await sqlite_instance.update_finding_async(
+ operation_id=other.id,
+ finding_id=original.id,
+ request=FindingCreate(title="Wrong operation", severity="critical"),
+ )
+ with pytest.raises(LookupError, match="not found"):
+ await sqlite_instance.delete_finding_async(operation_id=other.id, finding_id=original.id)
+ assert await sqlite_instance.get_findings_async() == [original]
+ await sqlite_instance.delete_finding_async(operation_id=operation.id, finding_id=original.id)
+ assert await sqlite_instance.get_findings_async() == []
+ assert len(await sqlite_instance.get_operations_async()) == 2
+ with pytest.raises(LookupError, match="not found"):
+ await sqlite_instance.delete_finding_async(operation_id=operation.id, finding_id=original.id)
+
+
+async def test_update_finding_validates_before_mutation(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Case"))
+ original = await sqlite_instance.add_finding_async(
+ Finding(operation_id=operation.id, title="Original", severity="low")
+ )
+ invalid = FindingCreate(title="Valid", severity="low").model_copy(update={"title": " "})
+ with pytest.raises(ValueError):
+ await sqlite_instance.update_finding_async(operation_id=operation.id, finding_id=original.id, request=invalid)
+ assert await sqlite_instance.get_findings_async() == [original]
+
+
+async def test_add_finding_evidence_and_get_page(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ finding = await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="A", severity="low"))
+ stamp = datetime(2026, 10, 8, tzinfo=UTC)
+ items = [
+ FindingEvidence(
+ id=UUID(int=i),
+ finding_id=finding.id,
+ conversation_id=f"conversation-{i}",
+ attack_result_id=uuid4(),
+ attached_at=stamp,
+ )
+ for i in [1, 2, 3]
+ ]
+ for evidence in items:
+ assert await sqlite_instance.add_finding_evidence_async(evidence=evidence) == evidence
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=2, offset=0) == items[:0:-1]
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=2, offset=2) == items[:1]
+ assert (
+ await sqlite_instance.get_finding_evidence_by_source_async(
+ finding_id=finding.id, conversation_id="conversation-1"
+ )
+ == items[0]
+ )
+ assert (
+ await sqlite_instance.get_finding_evidence_by_source_async(finding_id=uuid4(), conversation_id="conversation-1")
+ is None
+ )
+ assert await sqlite_instance.get_finding_async(operation_id=operation.id, finding_id=finding.id) == finding
+ with pytest.raises(ValueError):
+ await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=0, offset=0)
+
+
+async def test_finding_evidence_requires_an_existing_finding(sqlite_instance: SQLiteMemory) -> None:
+ evidence = FindingEvidence(finding_id=uuid4(), conversation_id="source", attack_result_id=uuid4())
+ with pytest.raises(LookupError):
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ async with await sqlite_instance.get_session_async() as session:
+ assert await session.scalar(select(func.count()).select_from(FindingEvidenceEntry)) == 0
+
+
+async def test_finding_evidence_foreign_key_failure_is_lookup_error(sqlite_instance: SQLiteMemory) -> None:
+ evidence = FindingEvidence(finding_id=uuid4(), conversation_id="source", attack_result_id=uuid4())
+ failure = IntegrityError("insert", {}, RuntimeError("foreign key"))
+ with patch.object(AsyncSession, "flush", side_effect=failure), pytest.raises(LookupError):
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+
+
+async def test_finding_evidence_unique_per_finding_conversation(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ finding = await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="A", severity="low"))
+ evidence = FindingEvidence(finding_id=finding.id, conversation_id="source", attack_result_id=uuid4())
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ duplicate = evidence.model_copy(update={"id": uuid4()})
+ assert await sqlite_instance.add_finding_evidence_async(evidence=duplicate) == evidence
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=20, offset=0) == [evidence]
+
+
+async def test_unrelated_integrity_failures_propagate(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ finding = await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="A", severity="low"))
+ evidence = FindingEvidence(finding_id=finding.id, conversation_id="source", attack_result_id=uuid4())
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ with pytest.raises(IntegrityError):
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence.model_copy(update={"conversation_id": "x"}))
+ with pytest.raises(IntegrityError):
+ await sqlite_instance.add_operation_async(operation.model_copy(update={"name": "Different"}))
+
+
+async def test_one_conversation_can_support_multiple_findings_and_counts(sqlite_instance: SQLiteMemory) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ findings = [
+ await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title=title, severity="low"))
+ for title in ["A", "B", "C"]
+ ]
+ for finding in findings[:2]:
+ await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, conversation_id="source", attack_result_id=uuid4())
+ )
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[f.id for f in findings]) == {
+ findings[0].id: 1,
+ findings[1].id: 1,
+ }
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[]) == {}
+
+
+async def test_delete_finding_removes_evidence_without_deleting_source(sqlite_instance: SQLiteMemory) -> None:
+ from pyrit.models import Message, MessagePiece
+
+ operation = await sqlite_instance.add_operation_async(Operation(name="Evidence"))
+ finding = await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title="A", severity="low"))
+ await sqlite_instance.add_message_to_memory_async(
+ request=Message(
+ message_pieces=[MessagePiece(role="user", original_value="Retain me", conversation_id="source")]
+ )
+ )
+ evidence = await sqlite_instance.add_finding_evidence_async(
+ evidence=FindingEvidence(finding_id=finding.id, conversation_id="source", attack_result_id=uuid4())
+ )
+ with pytest.raises(LookupError):
+ await sqlite_instance.delete_finding_async(operation_id=uuid4(), finding_id=finding.id)
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=20, offset=0) == [evidence]
+ await sqlite_instance.delete_finding_evidence_async(finding_id=finding.id, evidence_id=evidence.id)
+ assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[finding.id]) == {}
+ await sqlite_instance.add_finding_evidence_async(evidence=evidence)
+ await sqlite_instance.delete_finding_async(operation_id=operation.id, finding_id=finding.id)
+ assert await sqlite_instance.get_finding_evidence_async(finding_id=finding.id, limit=20, offset=0) == []
+ assert (await sqlite_instance.get_conversation_stats_async(conversation_ids=["source"]))[
+ "source"
+ ].message_count == 1
+
+
+async def test_finding_search_escapes_sql_server_bracket_wildcards(sqlite_instance: SQLiteMemory) -> None:
+ session = MagicMock(spec=AsyncSession)
+ session.__aenter__.return_value = session
+ result = MagicMock(spec=ScalarResult)
+ result.all.return_value = []
+ session.scalars.return_value = result
+ with patch.object(sqlite_instance, "get_session_async", return_value=session):
+ await sqlite_instance.get_findings_async(title_query="[Proof]")
+ statement = session.scalars.call_args.args[0]
+ compiled = statement.compile(dialect=mssql.dialect())
+ assert compiled.params["title_1"] == "%/[Proof]%"
+ assert "ESCAPE '/'" in str(compiled)
+
+
+@pytest.mark.parametrize("query", ["[Proof]", "100%_", "a/b", "a//b"])
+async def test_finding_search_treats_wildcards_and_escape_characters_literally(
+ sqlite_instance: SQLiteMemory,
+ query: str,
+) -> None:
+ operation = await sqlite_instance.add_operation_async(Operation(name="Literal search"))
+ for title in ["[Proof]", "100%_", "a/b", "a//b", "Proof", "100XX", "ab"]:
+ await sqlite_instance.add_finding_async(Finding(operation_id=operation.id, title=title, severity="low"))
+ findings = await sqlite_instance.get_findings_async(operation_id=operation.id, title_query=query)
+ assert [finding.title for finding in findings] == [query]
diff --git a/tests/unit/memory/test_operations_and_findings_migration.py b/tests/unit/memory/test_operations_and_findings_migration.py
new file mode 100644
index 0000000000..a2c8f7ee77
--- /dev/null
+++ b/tests/unit/memory/test_operations_and_findings_migration.py
@@ -0,0 +1,121 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import importlib
+import io
+from datetime import UTC, datetime
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+import sqlalchemy as sa
+from alembic import command
+from alembic.config import Config
+from alembic.migration import MigrationContext
+from alembic.operations import Operations
+from sqlalchemy.exc import IntegrityError
+
+from pyrit.memory.memory_models import FindingEvidenceEntry
+from pyrit.memory.migration import check_schema_migrations
+
+PREVIOUS_REVISION = "901e6c7bf9d4"
+REVISION = "c8d3e5f7a901"
+TABLES = {"OperationEntries", "FindingEntries", "FindingEvidenceEntries"}
+
+
+def _config_for(connection: sa.Connection) -> Config:
+ config = Config()
+ config.set_main_option("script_location", str(Path(__file__).resolve().parents[3] / "pyrit" / "memory" / "alembic"))
+ config.attributes["connection"] = connection
+ config.attributes["version_table"] = "pyrit_memory_alembic_version"
+ return config
+
+
+def _migration():
+ return importlib.import_module("pyrit.memory.alembic.versions.c8d3e5f7a901_add_operations_and_findings")
+
+
+def _columns(inspector: sa.Inspector, table: str) -> list[tuple[str, str, bool]]:
+ return [(c["name"], str(c["type"]), c["nullable"]) for c in inspector.get_columns(table)]
+
+
+def test_migration_adds_tables_without_changing_existing_ones() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, PREVIOUS_REVISION)
+ inspector = sa.inspect(connection)
+ existing = {name: _columns(inspector, name) for name in inspector.get_table_names()}
+ command.upgrade(config, REVISION)
+ inspector = sa.inspect(connection)
+ assert set(inspector.get_table_names()) == set(existing) | TABLES
+ assert {name: _columns(inspector, name) for name in existing} == existing
+ check_schema_migrations(engine=engine)
+ finally:
+ engine.dispose()
+
+
+def test_migration_downgrades_only_without_operations() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ config = _config_for(connection)
+ command.upgrade(config, REVISION)
+ command.downgrade(config, PREVIOUS_REVISION)
+ assert TABLES.isdisjoint(sa.inspect(connection).get_table_names())
+ command.upgrade(config, REVISION)
+ connection.execute(
+ sa.text(
+ "INSERT INTO OperationEntries (id, name, name_key, created_at) "
+ "VALUES ('00000000-0000-0000-0000-000000000001', 'Retain', 'retain', '2026-10-09 12:00:00')"
+ )
+ )
+ with pytest.raises(ValueError, match="operations"):
+ command.downgrade(config, PREVIOUS_REVISION)
+ assert connection.scalar(sa.text("SELECT name FROM OperationEntries")) == "Retain"
+ finally:
+ engine.dispose()
+
+
+def test_migration_refuses_offline_downgrade() -> None:
+ context = MigrationContext.configure(dialect_name="mssql", opts={"as_sql": True, "output_buffer": io.StringIO()})
+ with Operations.context(context), pytest.raises(ValueError, match="online"):
+ _migration().downgrade()
+
+
+def test_migration_enforces_unique_evidence_source() -> None:
+ engine = sa.create_engine("sqlite:///:memory:")
+ try:
+ with engine.begin() as connection:
+ command.upgrade(_config_for(connection), REVISION)
+ values = {
+ "finding_id": uuid4(),
+ "conversation_id": "source",
+ "attack_result_id": uuid4(),
+ "attached_at": datetime.now(UTC),
+ }
+ connection.execute(sa.insert(FindingEvidenceEntry).values(id=uuid4(), **values))
+ with pytest.raises(IntegrityError):
+ connection.execute(sa.insert(FindingEvidenceEntry).values(id=uuid4(), **values))
+ connection.execute(sa.insert(FindingEvidenceEntry).values(id=uuid4(), **{**values, "finding_id": uuid4()}))
+ finally:
+ engine.dispose()
+
+
+def test_migration_compiles_bounded_keys_for_sql_server() -> None:
+ output = io.StringIO()
+ context = MigrationContext.configure(dialect_name="mssql", opts={"as_sql": True, "output_buffer": output})
+ migration = _migration()
+ with Operations.context(context):
+ migration.upgrade()
+ ddl = output.getvalue()
+ assert migration.down_revision == PREVIOUS_REVISION
+ assert "NVARCHAR(128)" in ddl
+ assert "NVARCHAR(384)" in ddl
+ assert "VARCHAR(128)" in ddl
+ assert "UNIQUE (finding_id, conversation_id)" in ddl
+ assert "CREATE UNIQUE INDEX [ix_OperationEntries_name_key]" in ddl
+ assert ddl.count("FOREIGN KEY") == 2
+ assert "REFERENCES [OperationEntries]" in ddl
+ assert "REFERENCES [FindingEntries]" in ddl
diff --git a/tests/unit/models/test_finding.py b/tests/unit/models/test_finding.py
new file mode 100644
index 0000000000..e334e332fb
--- /dev/null
+++ b/tests/unit/models/test_finding.py
@@ -0,0 +1,85 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+from uuid import UUID
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.models.finding import Finding, FindingCreate
+from pyrit.models.harm_category import HarmCategory
+
+OPERATION_ID = UUID(int=1)
+
+
+@pytest.mark.parametrize("severity", ["critical", "important", "moderate", "low", "informational"])
+def test_finding_round_trips_named_severity(severity: str) -> None:
+ finding = Finding(operation_id=OPERATION_ID, title="Assessment", severity=severity)
+ assert Finding.model_validate_json(finding.model_dump_json()) == finding
+ assert finding.description == ""
+ assert finding.created_at.utcoffset().total_seconds() == 0
+
+
+def test_finding_round_trips_custom_classifications_without_normalizing_text() -> None:
+ finding = Finding.model_validate(
+ {
+ "operation_id": OPERATION_ID,
+ "title": "Assessment",
+ "severity": "other",
+ "severity_other": " Team assessment ",
+ "harm_type": "Other",
+ "harm_type_other": " Team harm ",
+ }
+ )
+ assert finding.severity.value == "other"
+ assert finding.severity_other == " Team assessment "
+ assert finding.harm_type.value == "Other"
+ assert finding.harm_type_other == " Team harm "
+ assert Finding.model_validate_json(finding.model_dump_json()) == finding
+
+
+def test_finding_harm_type_is_optional_and_accepts_every_canonical_category() -> None:
+ assert FindingCreate(title="Optional", severity="low").harm_type is None
+ for category in HarmCategory:
+ finding = FindingCreate.model_validate(
+ {
+ "title": "Classified",
+ "severity": "moderate",
+ "harm_type": category.value,
+ "harm_type_other": "Custom harm" if category == HarmCategory.OTHER else None,
+ }
+ )
+ assert finding.harm_type == category
+
+
+@pytest.mark.parametrize(
+ "fields",
+ [
+ {"severity": "other"},
+ {"severity": "other", "severity_other": " "},
+ {"severity": "low", "severity_other": "Stray custom severity"},
+ {"harm_type": "Other"},
+ {"harm_type": "Other", "harm_type_other": " "},
+ {"harm_type": "Malware", "harm_type_other": "Stray custom harm"},
+ {"harm_type_other": "Custom without category"},
+ {"harm_type": "Unknown category"},
+ ],
+)
+def test_finding_rejects_inconsistent_classifications(fields: dict[str, str]) -> None:
+ with pytest.raises(ValidationError):
+ FindingCreate.model_validate({"title": "Assessment", "severity": "low", **fields})
+
+
+@pytest.mark.parametrize("title", ["", " "])
+def test_finding_rejects_blank_title(title: str) -> None:
+ with pytest.raises(ValidationError):
+ FindingCreate(title=title, severity="low")
+
+
+def test_finding_rejects_unknown_input_and_requires_operation() -> None:
+ with pytest.raises(ValidationError):
+ FindingCreate(title="Assessment", severity="urgent")
+ with pytest.raises(ValidationError):
+ FindingCreate.model_validate({"title": "Assessment", "severity": "low", "operation": "Case"})
+ with pytest.raises(ValidationError):
+ Finding(title="Assessment", severity="low")
diff --git a/tests/unit/models/test_operation.py b/tests/unit/models/test_operation.py
new file mode 100644
index 0000000000..6f64292985
--- /dev/null
+++ b/tests/unit/models/test_operation.py
@@ -0,0 +1,30 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.models.operation import Operation, OperationCreate, operation_name_key
+
+
+def test_operation_trims_display_name_and_folds_key() -> None:
+ operation = Operation(name=" Operation A \t")
+ assert operation.name == "Operation A"
+ assert Operation.model_validate_json(operation.model_dump_json()) == operation
+ assert operation_name_key(" operation a ") == operation_name_key("OPERATION A") == "operation a"
+ assert operation_name_key("Straße") == operation_name_key("STRASSE")
+ assert operation_name_key("Cáse") != operation_name_key("Case")
+
+
+@pytest.mark.parametrize("name", ["", " \t"])
+def test_operation_rejects_blank_names(name: str) -> None:
+ with pytest.raises(ValidationError):
+ OperationCreate(name=name)
+
+
+def test_operation_limits_trimmed_name_by_utf16_code_units() -> None:
+ assert OperationCreate(name=" " + "😀" * 64 + " ").name == "😀" * 64
+ with pytest.raises(ValidationError):
+ OperationCreate(name="😀" * 65)
+ with pytest.raises(ValidationError):
+ OperationCreate.model_validate({"name": "Case", "id": "unexpected"})
From 67b8f097708f3a55ed43f009ab3c7d3f8d05f3b9 Mon Sep 17 00:00:00 2001
From: Adrian Gavrila
Date: Fri, 9 Oct 2026 18:11:22 -0400
Subject: [PATCH 2/3] Polish Operations views and add finding counts by
severity
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
frontend/src/App.labels.test.tsx | 11 +-
.../Labels/LabelsBar.operations.test.tsx | 28 +++--
.../src/components/Labels/LabelsBar.test.tsx | 109 +++---------------
frontend/src/components/Labels/LabelsBar.tsx | 3 +-
.../src/components/Labels/OperationPicker.tsx | 18 ++-
.../src/components/Layout/MainLayout.test.tsx | 3 +
.../Operations/FindingEvidenceList.styles.ts | 10 +-
.../Operations/FindingEvidenceList.tsx | 19 +--
.../Operations/OperationDetailPage.tsx | 4 +-
.../Operations/Operations.styles.ts | 11 ++
.../Operations/OperationsPage.test.tsx | 21 +++-
.../components/Operations/OperationsPage.tsx | 62 +++++++---
frontend/src/types/index.ts | 6 +-
pyrit/backend/models/operations.py | 10 +-
pyrit/backend/services/operation_service.py | 15 ++-
pyrit/memory/memory_interface.py | 16 +++
tests/unit/backend/test_operations.py | 3 +
tests/unit/memory/test_findings.py | 15 ++-
18 files changed, 218 insertions(+), 146 deletions(-)
diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx
index 650587904e..bdc3d30f34 100644
--- a/frontend/src/App.labels.test.tsx
+++ b/frontend/src/App.labels.test.tsx
@@ -91,6 +91,12 @@ const SCENARIO: RegisteredScenario = {
const TARGET = makeTarget({ target_registry_name: 'test_target', identifier_hash: 'test_hash' })
const DEFAULT_LABELS = { operator: 'config_user', operation: 'config_op', team: 'config_team' }
const SAVED_LABELS = { operator: 'original_user', operation: 'original_op', team: 'original_team' }
+const SAVED_OPERATIONS = {
+ items: [
+ 'config_op', 'config_op_v2', 'config_op_v3', 'new_default', 'original_op', 'test_op', 'remembered_op',
+ 'signed_in_op', 'early_choice', 'future_op', 'user_op',
+ ].map(name => ({ id: `operation-${name}`, name, created_at: '2026-10-07T16:00:00Z' })),
+}
const SCENARIO_PATH = '/scanner/test.scenario'
function renderApp(path = SCENARIO_PATH) {
@@ -106,9 +112,6 @@ function currentLabels(): HTMLElement {
}
async function chooseOperation(user: ReturnType, operation: string): Promise {
- 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}')
@@ -134,7 +137,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(operationsApi.list).mockResolvedValue(SAVED_OPERATIONS)
jest.mocked(targetsApi.listTargets).mockResolvedValue({
items: [TARGET], pagination: { limit: 200, has_more: false },
})
diff --git a/frontend/src/components/Labels/LabelsBar.operations.test.tsx b/frontend/src/components/Labels/LabelsBar.operations.test.tsx
index 12a118e187..2990f007cf 100644
--- a/frontend/src/components/Labels/LabelsBar.operations.test.tsx
+++ b/frontend/src/components/Labels/LabelsBar.operations.test.tsx
@@ -99,16 +99,30 @@ describe('saved operation workflow', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
- it('does not offer a legacy selection as a saved choice and allows removing it', async () => {
- const user = userEvent.setup()
+ it('silently clears a loaded operation that is not saved', async () => {
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 waitFor(() => expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice' }))
+ expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('')
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('keeps a loaded saved operation and allows removing it', async () => {
+ const user = userEvent.setup()
+ renderBar({ operator: 'alice', operation: SAVED.name })
+ await waitFor(() => expect(operationsApi.list).toHaveBeenCalled())
+ expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue(SAVED.name)
+ expect(onChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Remove operation label' }))
expect(onChange).toHaveBeenLastCalledWith({ operator: 'alice' })
- expect(screen.getByRole('combobox', { name: 'Operation' })).toBeInTheDocument()
+ })
+
+ it('keeps a loaded operation when saved operations cannot be checked', async () => {
+ jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('List unavailable'))
+ renderBar({ operator: 'alice', operation: 'Legacy / LABEL' })
+ await waitFor(() => expect(operationsApi.list).toHaveBeenCalled())
+ await act(async () => {})
+ expect(screen.getByRole('combobox', { name: 'Operation' })).toHaveValue('Legacy / LABEL')
+ expect(onChange).not.toHaveBeenCalled()
})
it('creates inline, immediately selects the saved response, and restores focus', async () => {
diff --git a/frontend/src/components/Labels/LabelsBar.test.tsx b/frontend/src/components/Labels/LabelsBar.test.tsx
index 23fee20b87..cce32be2b7 100644
--- a/frontend/src/components/Labels/LabelsBar.test.tsx
+++ b/frontend/src/components/Labels/LabelsBar.test.tsx
@@ -1092,7 +1092,7 @@ describe('LabelsBar', () => {
describe('operation picker', () => {
const OPERATIONS = ['op_2026_07_grok_45', 'op_2026_08_probe', 'validate-button-test']
- function renderWithOperations(onChange: jest.Mock, operations: string[] = OPERATIONS) {
+ function renderWithOperations(onChange: jest.Mock, operations: string[] = OPERATIONS, operation?: string) {
jest.mocked(operationsApi.list).mockResolvedValue({
items: operations.map((name, index) => ({ id: `operation-${index}`, name, created_at: '2026-10-07T16:00:00Z' })),
})
@@ -1104,7 +1104,7 @@ describe('LabelsBar', () => {
})
render(
-
+
)
}
@@ -1230,7 +1230,7 @@ describe('LabelsBar', () => {
it('should dismiss the picker on Escape without committing', async () => {
const onChange = jest.fn()
- renderWithOperations(onChange)
+ renderWithOperations(onChange, OPERATIONS, 'op_2026_08_probe')
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
fireEvent.click(screen.getByTestId('edit-label-operation'))
@@ -1239,7 +1239,7 @@ describe('LabelsBar', () => {
await waitFor(() => {
expect(input).toHaveAttribute('aria-expanded', 'false')
- expect(input).toHaveValue(DEFAULT_GLOBAL_LABELS.operation)
+ expect(input).toHaveValue('op_2026_08_probe')
})
expect(onChange).not.toHaveBeenCalled()
})
@@ -1295,13 +1295,13 @@ describe('LabelsBar', () => {
it('should move focus into the picker so it can be driven by keyboard', async () => {
const user = userEvent.setup()
const onChange = jest.fn()
- renderWithOperations(onChange)
+ renderWithOperations(onChange, OPERATIONS, 'op_2026_08_probe')
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
// 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)
+ expect(chip).toHaveValue('op_2026_08_probe')
chip.focus()
expect(chip).toHaveFocus()
await user.keyboard('{ArrowDown}')
@@ -1415,7 +1415,7 @@ describe('LabelsBar', () => {
jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom'))
render(
-
+
)
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
@@ -1459,38 +1459,6 @@ describe('LabelsBar', () => {
expect(screen.queryByRole('option', { name: 'Create "op_2026_09_fresh"' })).not.toBeInTheDocument()
})
- 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('edit-label-operation'))
-
- 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'), {
- target: { value: 'op_chosen_elsewhere' },
- })
- expect(
- screen.queryByRole('option', { name: 'Create "op_chosen_elsewhere"' })
- ).not.toBeInTheDocument()
- })
-
it('should allow removing a legacy selected operation', async () => {
const onChange = jest.fn()
mockedLabelsApi.getLabels.mockResolvedValue({
@@ -1512,30 +1480,6 @@ describe('LabelsBar', () => {
expect(screen.queryByText(/Only lowercase letters/)).not.toBeInTheDocument()
})
- 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('edit-label-operation'))
-
- 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 not accept a typed name while the saved list is loading or failed', async () => {
const onChange = jest.fn()
let rejectLabels: (reason: Error) => void = () => {}
@@ -1544,7 +1488,7 @@ describe('LabelsBar', () => {
)
render(
-
+
)
@@ -1566,7 +1510,7 @@ describe('LabelsBar', () => {
it('should still say the operations could not be loaded when one is already set', async () => {
const onChange = jest.fn()
- jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom'))
+ jest.mocked(operationsApi.list).mockRejectedValue(new Error('boom'))
render(
{
expect(screen.queryByRole('option', { name: 'op_already_set' })).not.toBeInTheDocument()
})
- 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('edit-label-operation'))
-
- 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 () => {
// The saved list usually does contain the operation in use, and it can
// sit anywhere in it — including past the cap.
@@ -1707,7 +1624,7 @@ describe('LabelsBar', () => {
jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom'))
render(
-
+
)
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
@@ -1735,7 +1652,7 @@ describe('LabelsBar', () => {
jest.mocked(operationsApi.list).mockRejectedValueOnce(new Error('boom'))
render(
-
+
)
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
@@ -1763,7 +1680,7 @@ describe('LabelsBar', () => {
jest.mocked(operationsApi.list).mockResolvedValue({ items: [] })
render(
-
+
)
await waitFor(() => expect(mockedLabelsApi.getLabels).toHaveBeenCalled())
@@ -1795,7 +1712,7 @@ describe('LabelsBar', () => {
)
render(
-
+
)
diff --git a/frontend/src/components/Labels/LabelsBar.tsx b/frontend/src/components/Labels/LabelsBar.tsx
index a95dd12407..bb7317a812 100644
--- a/frontend/src/components/Labels/LabelsBar.tsx
+++ b/frontend/src/components/Labels/LabelsBar.tsx
@@ -657,7 +657,8 @@ export default function LabelsBar({ labels, onLabelsChange, operatorReadOnly = f
}
}}>
Operation:
-
+ handleRemoveLabel('operation')} />
{labels.operation && } aria-label="Remove operation label"
data-testid="remove-label-operation" onClick={() => handleRemoveLabel('operation')} />}
diff --git a/frontend/src/components/Labels/OperationPicker.tsx b/frontend/src/components/Labels/OperationPicker.tsx
index 6b91d318a8..2ce6fbfa5b 100644
--- a/frontend/src/components/Labels/OperationPicker.tsx
+++ b/frontend/src/components/Labels/OperationPicker.tsx
@@ -12,9 +12,10 @@ const MAX_LISTED = 200
interface OperationPickerProps {
currentValue: string
onSelect: (name: string) => void
+ onMissing: () => void
}
-export default function OperationPicker({ currentValue, onSelect }: OperationPickerProps) {
+export default function OperationPicker({ currentValue, onSelect, onMissing }: OperationPickerProps) {
const styles = useLabelsBarStyles()
const rootRef = useRef(null)
const localInputRef = useRef(null)
@@ -26,6 +27,21 @@ export default function OperationPicker({ currentValue, onSelect }: OperationPic
const [settledRevision, setSettledRevision] = useState(-1)
const [error, setError] = useState('')
const loading = settledRevision !== revision
+ const onMissingRef = useRef(onMissing)
+ useEffect(() => { onMissingRef.current = onMissing }, [onMissing])
+
+ // A value restored from preferences or server defaults may name an operation
+ // that is not saved; clear it. If the check fails, keep the value.
+ useEffect(() => {
+ if (!currentValue) return
+ let ignore = false
+ operationsApi.list()
+ .then(result => {
+ if (!ignore && !result.items.some(operation => operation.name === currentValue)) onMissingRef.current()
+ })
+ .catch(() => {})
+ return () => { ignore = true }
+ }, [currentValue])
useEffect(() => {
if (!open) return
diff --git a/frontend/src/components/Layout/MainLayout.test.tsx b/frontend/src/components/Layout/MainLayout.test.tsx
index 4e240e0787..39f7b60f4d 100644
--- a/frontend/src/components/Layout/MainLayout.test.tsx
+++ b/frontend/src/components/Layout/MainLayout.test.tsx
@@ -18,6 +18,9 @@ jest.mock("../../services/api", () => ({
labelsApi: {
getLabels: jest.fn().mockResolvedValue({ labels: {} }),
},
+ operationsApi: {
+ list: jest.fn(() => new Promise(() => {})),
+ },
}));
// Mock Navigation to simplify testing
diff --git a/frontend/src/components/Operations/FindingEvidenceList.styles.ts b/frontend/src/components/Operations/FindingEvidenceList.styles.ts
index 5cfdfff6e2..d2cb404f44 100644
--- a/frontend/src/components/Operations/FindingEvidenceList.styles.ts
+++ b/frontend/src/components/Operations/FindingEvidenceList.styles.ts
@@ -3,9 +3,13 @@ import { makeStyles, tokens } from '@fluentui/react-components'
import { mobileTouchTarget } from '@/styles/touchTargets'
export const useFindingEvidenceListStyles = makeStyles({
- root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS },
- list: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM },
+ root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS, marginTop: tokens.spacingVerticalM },
+ region: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM },
+ list: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM, margin: 0 },
item: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS, overflowWrap: 'anywhere' },
- actions: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalS },
+ actions: {
+ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: tokens.spacingHorizontalS,
+ marginTop: tokens.spacingVerticalXS,
+ },
button: { ...mobileTouchTarget },
})
diff --git a/frontend/src/components/Operations/FindingEvidenceList.tsx b/frontend/src/components/Operations/FindingEvidenceList.tsx
index b5e10e9813..70de4a0fe6 100644
--- a/frontend/src/components/Operations/FindingEvidenceList.tsx
+++ b/frontend/src/components/Operations/FindingEvidenceList.tsx
@@ -4,6 +4,7 @@ import {
Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle,
MessageBar, MessageBarBody, Spinner, Text,
} from '@fluentui/react-components'
+import { ChevronLeftRegular, ChevronRightRegular, DeleteRegular } from '@fluentui/react-icons'
import { Link } from 'react-router'
import { operationsApi } from '@/services/api'
@@ -83,7 +84,7 @@ export default function FindingEvidenceList({ operationId, findingId, count, onD
{ setExpanded(value => !value) }}>Evidence ({count})
- {expanded &&
+ {expanded &&
{settledKey !== requestKey ? : error ? (
Could not load evidence: {error}{' '}
{ setRevision(value => value + 1) }}>Retry evidence
@@ -99,17 +100,21 @@ export default function FindingEvidenceList({ operationId, findingId, count, onD
Open conversation
) : Evidence unavailable}
- { setRemoving(item); setRemoveError('') }}>Remove link
+ } title="Remove link"
+ aria-label={`Remove link: ${item.conversation_id}`}
+ onClick={() => { setRemoving(item); setRemoveError('') }} />
))}
)}
- { setOffset(value => Math.max(0, value - PAGE_SIZE)) }}>Previous evidence
- { setOffset(page?.next_offset ?? 0) }}>Next evidence
+ } title="Previous evidence"
+ aria-label="Previous evidence" disabled={offset === 0 || busy}
+ onClick={() => { setOffset(value => Math.max(0, value - PAGE_SIZE)) }} />
+ } title="Next evidence"
+ aria-label="Next evidence"
+ disabled={settledKey !== requestKey || !page?.has_more || Boolean(error) || busy}
+ onClick={() => { setOffset(page?.next_offset ?? 0) }} />
}