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
29 changes: 29 additions & 0 deletions frontend/src/components/Operations/FindingDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,4 +194,33 @@ describe('FindingDialog', () => {
id: expect.any(String), attackResultId: 'attack-1',
})
})

it('shows the API field message on the Evidence attack ID and keeps it invalid until edited', async () => {
const user = userEvent.setup()
const rejection = Object.assign(new Error('Request failed'), {
isAxiosError: true,
response: {
status: 422,
data: {
detail: 'Request validation failed',
errors: [{ field: 'body.attack_result_id', message: 'Input should be a valid UUID', code: 'uuid_parsing' }],
},
},
})
const onSave = jest.fn().mockRejectedValueOnce(rejection).mockResolvedValue(undefined)
render(<FluentProvider theme={webLightTheme}><FindingDialog evidenceField onSave={onSave} onClose={jest.fn()} />
</FluentProvider>)
await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Bad evidence')
await user.type(screen.getByRole('textbox', { name: 'Evidence attack ID' }), 'not-a-uuid')
await waitFor(() => expect(screen.getByRole('button', { name: 'Save finding' })).toBeEnabled())
await user.click(screen.getByRole('button', { name: 'Save finding' }))

expect(await screen.findByText(/Input should be a valid UUID/)).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Evidence attack ID' })).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('button', { name: 'Save finding' })).toBeDisabled()

await user.type(screen.getByRole('textbox', { name: 'Evidence attack ID' }), 'x')
expect(screen.queryByText(/Input should be a valid UUID/)).not.toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: 'Save finding' })).toBeEnabled())
})
})
17 changes: 14 additions & 3 deletions frontend/src/components/Operations/FindingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default function FindingDialog({
const [draft, setDraft] = useState<FindingCreate>({ ...EMPTY_DRAFT, ...initialValues })
const [attackResultId, setAttackResultId] = useState('')
const [error, setError] = useState('')
const [attackResultIdError, setAttackResultIdError] = useState('')
const [saving, setSaving] = useState(false)
const submitting = useRef(false)
const attempt = useRef<{ signature: string; id: string } | null>(null)
Expand All @@ -51,7 +52,7 @@ export default function FindingDialog({
const [harmSearch, setHarmSearch] = useState('')
const classificationsValid = (draft.severity !== 'other' || Boolean(draft.severity_other?.trim()))
&& (draft.harm_type !== 'Other' || Boolean(draft.harm_type_other?.trim()))
const canSave = !saveDisabled && Boolean(draft.title.trim()) && classificationsValid && harmTypes !== null
const canSave = !saveDisabled && !attackResultIdError && Boolean(draft.title.trim()) && classificationsValid && harmTypes !== null
&& !optionsError

useEffect(() => {
Expand All @@ -75,13 +76,22 @@ export default function FindingDialog({
submitting.current = true
setSaving(true)
setError('')
setAttackResultIdError('')
const trimmedAttackResultId = attackResultId.trim()
const signature = JSON.stringify([draft, trimmedAttackResultId])
if (attempt.current?.signature !== signature) attempt.current = { signature, id: generateClientId() }
try {
await onSave(draft, { id: attempt.current.id, attackResultId: trimmedAttackResultId })
} catch (cause: unknown) {
setError(toApiError(cause).detail)
const apiError = toApiError(cause)
const evidenceMessages = apiError.fieldErrors.filter(item => item.field.split('.').pop() === 'attack_result_id')
.map(item => item.message)
const otherMessages = apiError.fieldErrors.filter(item => item.field.split('.').pop() !== 'attack_result_id')
.map(item => `${item.field.replace(/^body\./, '')}: ${item.message}`)
setAttackResultIdError(evidenceMessages.join(' '))
if (evidenceMessages.length === 0 || otherMessages.length > 0) {
setError([otherMessages.length > 0 ? '' : apiError.detail, ...otherMessages].filter(Boolean).join(' '))
}
} finally {
submitting.current = false
setSaving(false)
Expand Down Expand Up @@ -158,9 +168,10 @@ export default function FindingDialog({
onChange={(_, data) => { setDraft(value => ({ ...value, description: data.value })) }} />
</Field>
{evidenceField && <Field label="Evidence attack ID"
validationState={attackResultIdError ? 'error' : 'none'} validationMessage={attackResultIdError || undefined}
hint="Optional. Links a saved attack as evidence; the attack is not changed. Its ID is in the attack's address.">
<Input value={attackResultId} disabled={saving} name="attack_result_id" autoComplete="off"
onChange={(_, data) => { setAttackResultId(data.value) }} />
onChange={(_, data) => { setAttackResultId(data.value); setAttackResultIdError('') }} />
</Field>}
<div className={styles.actions}>
<Button className={styles.button} disabled={saving} onClick={onClose}>Cancel</Button>
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/services/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,29 @@ describe('toApiError', () => {
expect(result.detail).toBe('Field "name" is required.')
expect(result.type).toBe('validation_error')
})

it('preserves per-field validation errors from the RFC 7807 body', () => {
const err = makeAxiosError({
status: 422,
data: {
detail: 'Request validation failed',
errors: [
{ field: 'body.attack_result_id', message: 'Input should be a valid UUID', code: 'uuid_parsing' },
{ field: 'body.title', message: 42 },
'not-an-object',
],
},
})

expect(toApiError(err).fieldErrors).toEqual([
{ field: 'body.attack_result_id', message: 'Input should be a valid UUID', code: 'uuid_parsing' },
])
})

it('leaves fieldErrors empty when the body has no errors array', () => {
const err = makeAxiosError({ status: 500, data: { detail: 'boom' } })
expect(toApiError(err).fieldErrors).toEqual([])
})
})

describe('decodeBlobErrorAsync', () => {
Expand Down
39 changes: 34 additions & 5 deletions frontend/src/services/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export function conflictingOperation(cause: unknown): Operation | null {
return { id: operation.id, name: operation.name, created_at: operation.created_at }
}

export interface FieldError {
/** Location of the invalid input, e.g. `body.attack_result_id`. */
field: string
message: string
code?: string
}

/**
* Normalized error from any API call.
*
Expand All @@ -30,6 +37,8 @@ export interface ApiError {
detail: string
/** RFC 7807 `type` URI, if the backend included one. */
type?: string
/** Per-field validation errors from the RFC 7807 `errors` array; empty when the body has none. */
fieldErrors: FieldError[]
/** True when no HTTP response was received (DNS failure, CORS block, etc.). */
isNetworkError: boolean
/** True when the request exceeded the configured timeout. */
Expand Down Expand Up @@ -58,6 +67,7 @@ export function toApiError(err: unknown): ApiError {
return {
status: null,
detail: 'Request timed out. The server may be busy — please try again.',
fieldErrors: [],
isNetworkError: false,
isTimeout: true,
raw: err,
Expand All @@ -69,6 +79,7 @@ export function toApiError(err: unknown): ApiError {
return {
status: null,
detail: 'Network error — check that the backend is running and reachable.',
fieldErrors: [],
isNetworkError: true,
isTimeout: false,
raw: err,
Expand All @@ -77,12 +88,13 @@ export function toApiError(err: unknown): ApiError {

// We have an HTTP response — try to extract RFC 7807 detail
const { status, data } = err.response
const { detail, type } = extractDetail(data)
const { detail, type, fieldErrors } = extractDetail(data)

return {
status,
detail: detail || `Server error (${status})`,
type,
fieldErrors,
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -94,6 +106,7 @@ export function toApiError(err: unknown): ApiError {
return {
status: null,
detail: err.message || 'An unexpected error occurred.',
fieldErrors: [],
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -105,6 +118,7 @@ export function toApiError(err: unknown): ApiError {
return {
status: null,
detail: err,
fieldErrors: [],
isNetworkError: false,
isTimeout: false,
raw: err,
Expand All @@ -115,6 +129,7 @@ export function toApiError(err: unknown): ApiError {
return {
status: null,
detail: 'An unexpected error occurred.',
fieldErrors: [],
isNetworkError: false,
isTimeout: false,
raw: err,
Expand Down Expand Up @@ -180,15 +195,29 @@ function readBlobText(blob: Blob): Promise<string> {
* - A plain string (e.g. nginx HTML error page)
* - Something else entirely (null, number, etc.)
*/
function extractDetail(data: unknown): { detail: string | undefined; type: string | undefined } {
function extractDetail(data: unknown): {
detail: string | undefined; type: string | undefined; fieldErrors: FieldError[]
} {
if (typeof data === 'string') {
return { detail: data, type: undefined }
return { detail: data, type: undefined, fieldErrors: [] }
}
if (typeof data === 'object' && data !== null) {
const obj = data as Record<string, unknown>
const detail = typeof obj.detail === 'string' ? obj.detail : undefined
const type = typeof obj.type === 'string' ? obj.type : undefined
return { detail, type }
return { detail, type, fieldErrors: extractFieldErrors(obj.errors) }
}
return { detail: undefined, type: undefined, fieldErrors: [] }
}

function extractFieldErrors(errors: unknown): FieldError[] {
if (!Array.isArray(errors)) return []
const result: FieldError[] = []
for (const item of errors) {
if (typeof item !== 'object' || item === null) continue
const { field, message, code } = item as Record<string, unknown>
if (typeof field !== 'string' || typeof message !== 'string') continue
result.push(typeof code === 'string' ? { field, message, code } : { field, message })
}
return { detail: undefined, type: undefined }
return result
}
Loading