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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions frontend/e2e/labels-operation-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,32 +135,23 @@ async function openOperationPicker(page: Page) {
}

test.describe("operation picker placement", () => {
test("highlights the placeholder operator and requires resolving a proposed operation", async ({ page }) => {
test("requires resolving a proposed operation", async ({ page }) => {
await setupMocks(page, [], {
defaultLabels: { operator: "roakey" },
defaultOperation: "op_trash_panda",
savedOperations: ["op_trash_panda", "op_real"],
});
await page.goto("/");

const operator = page.getByTestId("edit-label-operator").locator("..");
const operation = page.getByTestId("edit-label-operation").locator("..");
await expect(operator).toHaveCSS("background-color", "rgb(254, 247, 178)");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_trash_panda");
await expect(operation).toHaveCSS("background-color", "rgb(254, 247, 178)");
await expect(page.getByTestId("edit-label-operation")).toHaveAccessibleDescription("Not registered");
await expect(page.getByRole("button", { name: "Register", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Choose existing", exact: true })).toHaveCount(0);

await page.getByTestId("edit-label-operator").fill("alice");
await page.getByTestId("edit-label-operator").press("Enter");
await expect(operator).not.toHaveCSS("background-color", "rgb(254, 247, 178)");

await page.getByTestId("edit-label-operation").click();
await page.getByRole("option", { name: "op_real", exact: true }).click();
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_real");
await expect(page.getByText("Not registered", { exact: true })).toHaveCount(0);
await expect(operation).not.toHaveCSS("background-color", "rgb(254, 247, 178)");
});

test("keeps metadata editable in a narrow bar without duplicating it in Default Labels", async ({ page }) => {
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/App.labels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,11 @@ describe('Shared new run labels', () => {
const picker = await within(currentLabels()).findByRole('combobox', { name: 'Operation' })
await waitFor(() => expect(picker).toHaveValue(proposed))
expect(within(currentLabels()).getByText('Not registered')).toBeInTheDocument()
expect(await screen.findByText(/Choose a saved Operation, register, or clear this one/)).toBeInTheDocument()
expect(screen.queryByText(/Choose a saved Operation, register, or clear this one/)).not.toBeInTheDocument()
await user.click(within(currentLabels()).getByRole('button', { name: 'Show warnings' }))
expect(await screen.findByRole('heading', { name: 'Warnings' })).toBeInTheDocument()
expect(screen.getByText(/Choose a saved Operation, register, or clear this one/)).toBeInTheDocument()
await user.keyboard('{Escape}')
await user.selectOptions(await screen.findByRole('combobox', { name: 'Objective Target' }), 'test_target')
expect(screen.getByRole('button', { name: 'Launch scan' })).toBeDisabled()
expect(operationsApi.create).not.toHaveBeenCalled()
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from './utils/targetIdentity'
import { attacksApi, authApi, versionApi } from './services/api'
import { toApiError } from './services/errors'
import { getOperationWarning } from '@/utils/operationWarnings'
import { useTour } from './hooks/useTour'
import { ChatDraftProvider, useChatDraftHandoff } from './hooks/useChatDraftHandoff'
import {
Expand Down Expand Up @@ -297,7 +298,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
const operationId = operation.state.status === 'resolved' ? operation.state.operation.id : null
const newActivityReady = defaultsReady && operationReady
const operationBlockedReason = defaultsReady && !operationReady && operation.state.status !== 'loading'
? 'Choose a saved Operation, register, or clear this one to start new attacks and scans.'
? getOperationWarning(operation.state)
: undefined
const { remember: rememberOperation } = operation
const handleOperationChange = useCallback((selected: Operation | null) => {
Expand Down Expand Up @@ -846,11 +847,6 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
</MessageBarBody>
</MessageBar>
)}
{operationBlockedReason && (
<MessageBar intent="warning">
<MessageBarBody>{operationBlockedReason}</MessageBarBody>
</MessageBar>
)}
{preferenceError && (
<MessageBar intent="warning">
<MessageBarBody>{preferenceError}</MessageBarBody>
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/Labels/LabelsBar.operations.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ describe('saved operation workflow', () => {
] as OperationSelectionState[])('chooses an existing operation or clears from %j', async (initial: OperationSelectionState) => {
const user = userEvent.setup()
renderBar(initial)
if (initial.status === 'missing') expect(screen.getByText('Saved operation not found')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Show warnings' }))
expect(screen.getByText(initial.status === 'missing'
? /The saved operation was not found/ : /Choose a saved Operation/)).toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('combobox', { name: 'Operation' }))
await user.click(await screen.findByRole('option', { name: SAVED.name }))
expect(onSelect).toHaveBeenLastCalledWith(SAVED)
Expand All @@ -174,7 +177,9 @@ describe('saved operation workflow', () => {
it('keeps the selection and offers retry when the selected operation cannot be loaded', async () => {
const user = userEvent.setup()
renderBar({ status: 'error', error: 'Storage unavailable' })
await user.click(screen.getByRole('button', { name: 'Show warnings' }))
expect(screen.getByText(/Could not load the selected operation: Storage unavailable/)).toBeInTheDocument()
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledTimes(1)
expect(onSelect).not.toHaveBeenCalled()
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/Labels/LabelsBar.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ export const useLabelsBarStyles = makeStyles({
},
metadata: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalM, minWidth: 0 },
metadataField: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalXS, minWidth: 0 },
metadataPlaceholder: {
backgroundColor: tokens.colorPaletteYellowBackground2,
metadataWarning: {
backgroundColor: tokens.colorPaletteYellowBackground1,
border: `1px solid ${tokens.colorPaletteYellowBorder1}`,
},
srOnly: {
Expand Down Expand Up @@ -199,6 +199,8 @@ export const useLabelsBarStyles = makeStyles({
},
warningIcon: {
color: tokens.colorPaletteYellowForeground2,
backgroundColor: tokens.colorPaletteYellowBackground1,
border: `1px solid ${tokens.colorPaletteYellowBorder1}`,
display: 'flex',
alignItems: 'center',
flexShrink: 0,
Expand Down
18 changes: 6 additions & 12 deletions frontend/src/components/Labels/LabelsBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,6 @@ describe('LabelsBar', () => {
expect(screen.getByTestId('edit-label-operation')).toHaveValue('op_saved')
})

it('should show warning icon for dummy values', () => {
render(
<TestWrapper>
<LabelsBar labels={{ ...DEFAULT_GLOBAL_LABELS }} onLabelsChange={jest.fn()} />
</TestWrapper>
)

expect(screen.getByTestId('labels-warning')).toBeInTheDocument()
})

it.each([
['a placeholder', { operator: 'roakey' }, false, 'Placeholder value — type to change'],
['the signed-in account', { operator: 'alice' }, true, 'Derived from your signed-in account'],
Expand Down Expand Up @@ -105,10 +95,13 @@ describe('LabelsBar', () => {
expect(onChange).not.toHaveBeenCalled()
})

it('should not show warning when values are customized', () => {
it.each([
{ operator: 'alice', operatorReadOnly: false },
{ operator: 'roakey', operatorReadOnly: true },
])('should not warn for operator $operator when read-only is $operatorReadOnly', ({ operator, operatorReadOnly }) => {
render(
<TestWrapper>
<LabelsBar labels={{ operator: 'alice', operation: 'my_test' }} onLabelsChange={jest.fn()}
<LabelsBar labels={{ operator }} operatorReadOnly={operatorReadOnly} onLabelsChange={jest.fn()}
operation={{ status: 'resolved', operation: SAVED_OPERATION }} />
</TestWrapper>
)
Expand All @@ -121,6 +114,7 @@ describe('LabelsBar', () => {
labels: { ...DEFAULT_GLOBAL_LABELS },
message: 'Set operator in the bar. The current value is a placeholder.',
},
{ labels: {}, message: 'Set operator in the bar. No operator is set.' },
])('should explain the warning: $message', async ({ labels, message }: {
labels: Record<string, string>
message: string
Expand Down
36 changes: 16 additions & 20 deletions frontend/src/components/Labels/LabelsBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import {
Text,
Button,
Expand All @@ -18,8 +18,10 @@ import {
import type { OperationSelectionState } from '@/hooks/useOperationSelection'
import { labelsApi } from '@/services/api'
import type { Operation } from '@/types'
import { getOperationWarning } from '@/utils/operationWarnings'
import OperationPicker from './OperationPicker'
import { useLabelsBarStyles } from './LabelsBar.styles'
import { DEFAULT_GLOBAL_LABELS } from './labelDefaults'


const validateValue = (value: string): string | null => {
Expand All @@ -30,10 +32,6 @@ const validateValue = (value: string): string | null => {
return null
}

const DUMMY_VALUES: Record<string, string> = {
operator: 'roakey',
}

const METADATA_KEYS = new Set(['operator', 'operation'])

export interface OperationControlProps {
Expand Down Expand Up @@ -107,22 +105,19 @@ export default function LabelsBar({
.catch(() => setError('Could not load label suggestions.'))
}, [])

const isDummyValue = useCallback((key: string, value: string): boolean => {
return DUMMY_VALUES[key] === value
}, [])

const placeholderKeys = Object.entries(labels)
.filter(([key, value]) => isDummyValue(key, value))
.map(([key]) => key)
const operatorMissing = !labels.operator?.trim()
const operatorPlaceholder = labels.operator === DEFAULT_GLOBAL_LABELS.operator
const operatorWarning = operatorReadOnly ? null
: operatorMissing ? 'Set operator in the bar. No operator is set.'
: operatorPlaceholder ? 'Set operator in the bar. The current value is a placeholder.' : null
const operationWarning = getOperationWarning(operation)
const warnings = [
...(placeholderKeys.length > 0
? [`Set ${placeholderKeys.join(' and ')} in the bar. ${
placeholderKeys.length === 1 ? 'The current value is a placeholder.' : 'The current values are placeholders.'
}`] : []),
...(operation.status === 'empty' ? ['Set operation in the bar. No operation is selected.'] : []),
...(operatorWarning ? [operatorWarning] : []),
...(operationWarning ? [operationWarning] : []),
]
const operatorTip = operatorReadOnly ? 'Derived from your signed-in account'
: isDummyValue('operator', labels.operator ?? '') ? 'Placeholder value — type to change' : null
: operatorMissing ? 'No operator is set. Type your operator name.'
: operatorPlaceholder ? 'Placeholder value — type to change' : null

const validateKey = (key: string): string | null => {
if (!key) return 'Key is required'
Expand Down Expand Up @@ -593,7 +588,7 @@ export default function LabelsBar({
<Tooltip content={operatorTip ?? ''} relationship="description"
{...(operatorTip ? {} : { visible: false })}>
<Input size="small" className={mergeClasses(styles.operatorInput,
isDummyValue('operator', labels.operator ?? '') ? styles.metadataPlaceholder : undefined)}
operatorWarning ? styles.metadataWarning : undefined)}
value={editingLabel === 'operator' ? editValue : labels.operator ?? ''}
readOnly={operatorReadOnly} aria-label={operatorReadOnly ? 'Signed-in operator' : 'Operator'}
data-testid="edit-label-operator"
Expand Down Expand Up @@ -646,7 +641,8 @@ export default function LabelsBar({
}
}}>
<Text size={200} weight="semibold">Operation:</Text>
<OperationPicker selection={operation} onSelect={onOperationChange} onRetry={onRetryOperation} />
<OperationPicker selection={operation} warning={operationWarning}
onSelect={onOperationChange} onRetry={onRetryOperation} />
</div>
</div>
{headerEntries
Expand Down
21 changes: 8 additions & 13 deletions frontend/src/components/Labels/OperationPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { Button, Combobox, MessageBar, MessageBarBody, mergeClasses, Option, Text, useId } from '@fluentui/react-components'
import { Button, Combobox, MessageBar, MessageBarBody, mergeClasses, Option, useId } from '@fluentui/react-components'
import { DismissRegular } from '@fluentui/react-icons'

import OperationCreateDialog from '@/components/Operations/OperationCreateDialog'
Expand All @@ -13,11 +13,12 @@ const MAX_LISTED = 200

interface OperationPickerProps {
selection: OperationSelectionState
warning?: string
onSelect: (operation: Operation | null) => void
onRetry: () => void
}

export default function OperationPicker({ selection, onSelect, onRetry }: OperationPickerProps) {
export default function OperationPicker({ selection, warning, onSelect, onRetry }: OperationPickerProps) {
const styles = useLabelsBarStyles()
const unregisteredId = useId('operation-unregistered')
const rootRef = useRef<HTMLDivElement>(null)
Expand Down Expand Up @@ -71,8 +72,7 @@ export default function OperationPicker({ selection, onSelect, onRetry }: Operat
<div className={styles.operationStatus}>
<Combobox
ref={localInputRef}
className={mergeClasses(styles.operationPicker,
selection.status === 'unregistered' ? styles.metadataPlaceholder : undefined)}
className={mergeClasses(styles.operationPicker, warning ? styles.metadataWarning : undefined)}
aria-describedby={selection.status === 'unregistered' ? unregisteredId : undefined}
size="small"
freeform
Expand Down Expand Up @@ -128,18 +128,13 @@ export default function OperationPicker({ selection, onSelect, onRetry }: Operat
<span id={unregisteredId} className={styles.srOnly}>Not registered</span>
<Button size="small" className={styles.actionButton} onClick={() => startCreate(selection.name)}>Register</Button>
</>}
{selection.status === 'missing' && <Text size={200} className={styles.operationNoteError}>Saved operation not found</Text>}
{(selection.status === 'unregistered' || selection.status === 'missing') && (
{selection.status === 'error' && (
<Button size="small" className={styles.actionButton} onClick={onRetry}>Retry</Button>
)}
{(selection.status === 'unregistered' || selection.status === 'missing' || selection.status === 'error') && (
<Button size="small" className={styles.actionButton} onClick={() => onSelect(null)}>Clear</Button>
)}
</div>
{selection.status === 'error' && (
<MessageBar intent="error"><MessageBarBody>
Could not load the selected operation: {selection.error}{' '}
<Button size="small" className={styles.actionButton} onClick={onRetry}>Retry</Button>
<Button size="small" className={styles.actionButton} onClick={() => onSelect(null)}>Clear</Button>
</MessageBarBody></MessageBar>
)}
{!loading && error && (
<MessageBar intent="error"><MessageBarBody>
{error}{' '}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/utils/operationWarnings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { OperationSelectionState } from '@/hooks/useOperationSelection'

export function getOperationWarning(selection: OperationSelectionState): string | undefined {
switch (selection.status) {
case 'empty':
return 'Set operation in the bar. No operation is selected.'
case 'unregistered':
return 'Choose a saved Operation, register, or clear this one to start new attacks and scans.'
case 'missing':
return 'The saved operation was not found. Select another operation or clear it to start new attacks and scans.'
case 'error':
return `Could not load the selected operation: ${selection.error}. Retry or clear it in the bar to start new attacks and scans.`
default:
return undefined
}
}
Loading