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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ 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
### Operations

An operation groups related red-teaming work. Inside it you record findings:
human assessments, whether or not an attack produced them.
Expand All @@ -658,9 +658,14 @@ scope. 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.

On an operation's page, **Add description** or **Edit description** changes the
description. Saving replaces it, so if two people edit at once, the last save wins.
Operations can't be renamed or deleted from the GUI.
Renaming doesn't update anything that stores the old name: Analytics filters and
links, history from before Operation IDs, and names set in configuration,
`GLOBAL_MEMORY_LABELS`, or `--memory-labels`.

**Delete** works only when no findings, attacks, or scanner runs are linked; the
tooltip shows what's left. History from before Operation IDs doesn't count. SDK
attacks still running under the operation save their results without one.
Deleting can't be undone.

#### Findings

Expand Down
53 changes: 53 additions & 0 deletions frontend/e2e/operations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,59 @@ test('creates a unique operation and records findings within it @seeded', async
await expect(page.getByRole('button', { name: 'New finding' })).toBeFocused()
})

test('renames an operation and deletes it only once it is empty @seeded', async ({ page, request }) => {
test.setTimeout(90_000)
const stamp = Date.now()
const version = await (await request.get('/api/version')).json()
const headers = { 'PyRIT-Compatibility-ID': version.compatibility_id }
const taken: Operation = await (await request.post('/api/operations', {
headers, data: { name: `Taken ${stamp}` },
})).json()
const operation: Operation = await (await request.post('/api/operations', {
headers, data: { name: `Typo operation ${stamp}` },
})).json()
const finding: Finding = await (await request.post(`/api/operations/${operation.id}/findings`, {
headers, data: { title: 'Blocks deletion', description: '', severity: 'low' },
})).json()

await page.goto(`/operations/${operation.id}`)
const remove = page.getByRole('button', { name: 'Delete', exact: true })
await expect(remove).toBeDisabled()
await remove.focus()
await expect(page.getByRole('tooltip')).toHaveText('Has 1 finding. Only empty operations can be deleted.')

const edit = page.getByRole('button', { name: 'Edit', exact: true })
await edit.click()
let dialog = page.getByRole('dialog', { name: 'Edit operation' })
await dialog.getByRole('textbox', { name: 'Name' }).fill(` taken ${stamp} `)
await dialog.getByRole('button', { name: 'Save operation' }).click()
await expect(dialog.getByText(`Another operation is already named “${taken.name}”`)).toBeVisible()
await dialog.getByRole('textbox', { name: 'Name' }).fill(operation.name.toUpperCase())
await dialog.getByRole('button', { name: 'Save operation' }).click()
await expect(page.getByRole('heading', { level: 1, name: operation.name.toUpperCase() })).toBeVisible()
await expect(edit).toBeFocused()
const renamed = `Fixed operation ${stamp}`
await edit.click()
await dialog.getByRole('textbox', { name: 'Name' }).fill(renamed)
await dialog.getByRole('button', { name: 'Save operation' }).click()
await expect(page.getByRole('heading', { level: 1, name: renamed })).toBeVisible()

await page.getByRole('button', { name: `Delete finding: ${finding.title}` }).click()
await page.getByRole('dialog').getByRole('button', { name: 'Delete finding' }).click()
await expect(remove).toBeEnabled()
await remove.click()
dialog = page.getByRole('dialog', { name: 'Delete operation?' })
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(remove).toBeFocused()
await remove.click()
await dialog.getByRole('button', { name: 'Delete operation' }).click()
await expect(page).toHaveURL(/\/operations$/)
await expect(page.getByRole('heading', { name: 'Operations', exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: taken.name })).toBeVisible()
await expect(page.getByRole('link', { name: renamed })).toHaveCount(0)
expect((await request.get(`/api/operations/${operation.id}`, { headers })).status()).toBe(404)
})

test('selects saved operations inline and revisits ID-scoped attack history @seeded', async ({ page, request }) => {
test.setTimeout(90_000)
const name = `Engagement / α% & ${Date.now()}`
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
<Route path="/analytics" element={<AnalyticsPage onOpenAttack={handleOpenAttack} />} />
<Route path="/history" element={<LegacyAttackHistoryRedirect />} />
<Route path="/operations" element={<OperationsPage />} />
<Route path="/operations/:operationId" element={<OperationDetailPage />} />
<Route path="/operations/:operationId" element={<OperationDetailPage onOperationChanged={operation.retry} />} />
<Route path="/findings" element={<Navigate to="/operations" replace />} />
<Route
path={HISTORY_ATTACKS_PATH}
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/components/Operations/OperationDeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useRef, useState } from 'react'
import {
Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger,
MessageBar, MessageBarBody, Text, Tooltip,
} from '@fluentui/react-components'

import { operationsApi } from '@/services/api'
import { toApiError } from '@/services/errors'
import type { OperationDetail, OperationUsage } from '@/types'
import { useOperationsStyles } from './Operations.styles'

interface OperationDeleteDialogProps {
operation: OperationDetail
/** Called when the server refuses because something now refers to the Operation. */
onInUse: () => void
onDeleted: () => void
}

function countLabel(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`
}

/** Why an Operation can't be deleted, or undefined when nothing refers to it. */
function deleteBlockedReason({ findings, attacks, scenario_runs: runs }: OperationUsage): string | undefined {
const parts = [
findings > 0 ? countLabel(findings, 'finding', 'findings') : '',
attacks + runs > 0 ? countLabel(attacks + runs, 'linked attack or run', 'linked attacks or runs') : '',
].filter(Boolean)
return parts.length > 0 ? `Has ${parts.join(' and ')}. Only empty operations can be deleted.` : undefined
}

export default function OperationDeleteDialog({ operation, onInUse, onDeleted }: OperationDeleteDialogProps) {
const styles = useOperationsStyles()
const [open, setOpen] = useState(false)
const [error, setError] = useState('')
const [deleting, setDeleting] = useState(false)
const submitting = useRef(false)
const blockedReason = deleteBlockedReason(operation.usage)

const remove = async (): Promise<void> => {
if (submitting.current) return
submitting.current = true
setDeleting(true)
setError('')
try {
await operationsApi.delete(operation.id)
setOpen(false)
onDeleted()
} catch (cause: unknown) {
const apiError = toApiError(cause)
if (apiError.status === 409) onInUse()
setError(apiError.status === 409 ? 'This operation is no longer empty, so it can’t be deleted.' : apiError.detail)
} finally {
submitting.current = false
setDeleting(false)
}
}

return (
<Dialog open={open} onOpenChange={(_, data) => {
if (deleting) return
if (data.open) setError('')
setOpen(data.open)
}}>
<DialogTrigger disableButtonEnhancement>
<Tooltip relationship="description" content={blockedReason ?? 'Delete this empty operation.'}>
<Button className={styles.button} disabledFocusable={blockedReason !== undefined}>Delete</Button>
</Tooltip>
</DialogTrigger>
<DialogSurface>
<DialogBody>
<DialogTitle>Delete operation?</DialogTitle>
<DialogContent className={styles.form}>
<Text>
Delete “<span translate="no">{operation.name}</span>”? No saved findings, attacks, or scanner runs
use it. SDK attacks still running under it will save their results without an operation, and older
history recorded under its name stays as it is. This cannot be undone.
</Text>
{error && <MessageBar intent="error" aria-live="polite"><MessageBarBody>{error}</MessageBarBody></MessageBar>}
<div className={styles.actions}>
<DialogTrigger disableButtonEnhancement>
<Button className={styles.button} disabled={deleting}>Cancel</Button>
</DialogTrigger>
<Button appearance="primary" className={styles.button}
disabledFocusable={deleting || blockedReason !== undefined}
onClick={() => { void remove() }}>{deleting ? 'Deleting…' : 'Delete operation'}</Button>
</div>
</DialogContent>
</DialogBody>
</DialogSurface>
</Dialog>
)
}
Loading
Loading