[Security Solution][Exceptions] - Improve UX for missing exception list associated with rule (#75898)

## Summary

**Current behavior:**
  - **Scenario 1:** User is in the exceptions viewer flow, they select to edit an exception item, but the list the item is associated with has since been deleted (let's say by another user) - a user is able to open modal to edit exception item and on save, an error toaster shows but no information is given to the user to indicate the issue.
  - **Scenario 2:** User exports rules from space 'X' and imports into space 'Y'. The exception lists associated with their newly imported rules do not exist in space 'Y' - a user goes to add an exception item and gets a modal with an error, unable to add any exceptions. 
  - **Workaround:** current workaround exists only via API - user would need to remove the exception list from their rule via API

**New behavior:**
  - **Scenario 1:** User is still able to oped edit modal, but on save they see an error explaining that the associated exception list does not exist and prompts them to remove the exception list --> now they're able to add exceptions to their rule
  - **Scenario 2:** User navigates to exceptions after importing their rule, tries to add  exception, modal pops up with error informing them that they need to remove association to missing exception list, button prompts them to do so --> now can continue adding exceptions to rule
This commit is contained in:
Yara Tercero 2020-08-26 10:16:17 -04:00 committed by GitHub
parent 4e1b1b5d9e
commit b9c8201202
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 706 additions and 54 deletions

View file

@ -18,7 +18,6 @@ import {
EuiCheckbox,
EuiSpacer,
EuiFormRow,
EuiCallOut,
EuiText,
} from '@elastic/eui';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
@ -28,6 +27,7 @@ import {
ExceptionListType,
} from '../../../../../public/lists_plugin_deps';
import * as i18n from './translations';
import * as sharedI18n from '../translations';
import { TimelineNonEcsData, Ecs } from '../../../../graphql/types';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useKibana } from '../../../lib/kibana';
@ -35,6 +35,7 @@ import { ExceptionBuilderComponent } from '../builder';
import { Loader } from '../../loader';
import { useAddOrUpdateException } from '../use_add_exception';
import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list';
import { AddExceptionComments } from '../add_exception_comments';
import {
@ -46,6 +47,7 @@ import {
entryHasNonEcsType,
getMappedNonEcsValue,
} from '../helpers';
import { ErrorInfo, ErrorCallout } from '../error_callout';
import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules';
export interface AddExceptionModalBaseProps {
@ -107,13 +109,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({
}: AddExceptionModalProps) {
const { http } = useKibana().services;
const [comment, setComment] = useState('');
const { rule: maybeRule } = useRuleAsync(ruleId);
const [shouldCloseAlert, setShouldCloseAlert] = useState(false);
const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false);
const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false);
const [exceptionItemsToAdd, setExceptionItemsToAdd] = useState<
Array<ExceptionListItemSchema | CreateExceptionListItemSchema>
>([]);
const [fetchOrCreateListError, setFetchOrCreateListError] = useState(false);
const [fetchOrCreateListError, setFetchOrCreateListError] = useState<ErrorInfo | null>(null);
const { addError, addSuccess } = useAppToasts();
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
const [
@ -164,17 +167,41 @@ export const AddExceptionModal = memo(function AddExceptionModal({
},
[onRuleChange]
);
const onFetchOrCreateExceptionListError = useCallback(
(error: Error) => {
setFetchOrCreateListError(true);
const handleDissasociationSuccess = useCallback(
(id: string): void => {
handleRuleChange(true);
addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id));
onCancel();
},
[handleRuleChange, addSuccess, onCancel]
);
const handleDissasociationError = useCallback(
(error: Error): void => {
addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR });
onCancel();
},
[addError, onCancel]
);
const handleFetchOrCreateExceptionListError = useCallback(
(error: Error, statusCode: number | null, message: string | null) => {
setFetchOrCreateListError({
reason: error.message,
code: statusCode,
details: message,
listListId: null,
});
},
[setFetchOrCreateListError]
);
const [isLoadingExceptionList, ruleExceptionList] = useFetchOrCreateRuleExceptionList({
http,
ruleId,
exceptionListType,
onError: onFetchOrCreateExceptionListError,
onError: handleFetchOrCreateExceptionListError,
onSuccess: handleRuleChange,
});
@ -279,7 +306,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({
]);
const isSubmitButtonDisabled = useMemo(
() => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0),
() =>
fetchOrCreateListError != null ||
exceptionItemsToAdd.every((item) => item.entries.length === 0),
[fetchOrCreateListError, exceptionItemsToAdd]
);
@ -295,19 +324,27 @@ export const AddExceptionModal = memo(function AddExceptionModal({
</ModalHeaderSubtitle>
</ModalHeader>
{fetchOrCreateListError === true && (
<EuiCallOut title={i18n.ADD_EXCEPTION_FETCH_ERROR_TITLE} color="danger" iconType="alert">
<p>{i18n.ADD_EXCEPTION_FETCH_ERROR}</p>
</EuiCallOut>
{fetchOrCreateListError != null && (
<EuiModalFooter>
<ErrorCallout
http={http}
errorInfo={fetchOrCreateListError}
rule={maybeRule}
onCancel={onCancel}
onSuccess={handleDissasociationSuccess}
onError={handleDissasociationError}
data-test-subj="addExceptionModalErrorCallout"
/>
</EuiModalFooter>
)}
{fetchOrCreateListError === false &&
{fetchOrCreateListError == null &&
(isLoadingExceptionList ||
isIndexPatternLoading ||
isSignalIndexLoading ||
isSignalIndexPatternLoading) && (
<Loader data-test-subj="loadingAddExceptionModal" size="xl" />
)}
{fetchOrCreateListError === false &&
{fetchOrCreateListError == null &&
!isSignalIndexLoading &&
!isSignalIndexPatternLoading &&
!isLoadingExceptionList &&
@ -377,20 +414,21 @@ export const AddExceptionModal = memo(function AddExceptionModal({
</ModalBodySection>
</>
)}
{fetchOrCreateListError == null && (
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiButton
data-test-subj="add-exception-confirm-button"
onClick={onAddExceptionConfirm}
isLoading={addExceptionIsLoading}
isDisabled={isSubmitButtonDisabled}
fill
>
{i18n.ADD_EXCEPTION}
</EuiButton>
</EuiModalFooter>
<EuiButton
data-test-subj="add-exception-confirm-button"
onClick={onAddExceptionConfirm}
isLoading={addExceptionIsLoading}
isDisabled={isSubmitButtonDisabled}
fill
>
{i18n.ADD_EXCEPTION}
</EuiButton>
</EuiModalFooter>
)}
</Modal>
</EuiOverlayMask>
);

View file

@ -77,6 +77,7 @@ describe('When the edit exception modal is opened', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={[]}
ruleId="123"
ruleName={ruleName}
exceptionListType={'endpoint'}
onCancel={jest.fn()}
@ -105,6 +106,7 @@ describe('When the edit exception modal is opened', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={['filebeat-*']}
ruleId="123"
ruleName={ruleName}
exceptionListType={'endpoint'}
onCancel={jest.fn()}
@ -147,6 +149,7 @@ describe('When the edit exception modal is opened', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={['filebeat-*']}
ruleId="123"
ruleName={ruleName}
exceptionListType={'endpoint'}
onCancel={jest.fn()}
@ -190,6 +193,7 @@ describe('When the edit exception modal is opened', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={['filebeat-*']}
ruleId="123"
ruleName={ruleName}
exceptionListType={'detection'}
onCancel={jest.fn()}
@ -229,6 +233,7 @@ describe('When the edit exception modal is opened', () => {
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<EditExceptionModal
ruleIndices={['filebeat-*']}
ruleId="123"
ruleName={ruleName}
exceptionListType={'detection'}
onCancel={jest.fn()}

View file

@ -23,12 +23,14 @@ import {
} from '@elastic/eui';
import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules';
import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
import {
ExceptionListItemSchema,
CreateExceptionListItemSchema,
ExceptionListType,
} from '../../../../../public/lists_plugin_deps';
import * as i18n from './translations';
import * as sharedI18n from '../translations';
import { useKibana } from '../../../lib/kibana';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { ExceptionBuilderComponent } from '../builder';
@ -43,14 +45,17 @@ import {
lowercaseHashValues,
} from '../helpers';
import { Loader } from '../../loader';
import { ErrorInfo, ErrorCallout } from '../error_callout';
interface EditExceptionModalProps {
ruleName: string;
ruleId: string;
ruleIndices: string[];
exceptionItem: ExceptionListItemSchema;
exceptionListType: ExceptionListType;
onCancel: () => void;
onConfirm: () => void;
onRuleChange?: () => void;
}
const Modal = styled(EuiModal)`
@ -83,14 +88,18 @@ const ModalBodySection = styled.section`
export const EditExceptionModal = memo(function EditExceptionModal({
ruleName,
ruleId,
ruleIndices,
exceptionItem,
exceptionListType,
onCancel,
onConfirm,
onRuleChange,
}: EditExceptionModalProps) {
const { http } = useKibana().services;
const [comment, setComment] = useState('');
const { rule: maybeRule } = useRuleAsync(ruleId);
const [updateError, setUpdateError] = useState<ErrorInfo | null>(null);
const [hasVersionConflict, setHasVersionConflict] = useState(false);
const [shouldBulkCloseAlert, setShouldBulkCloseAlert] = useState(false);
const [shouldDisableBulkClose, setShouldDisableBulkClose] = useState(false);
@ -108,18 +117,44 @@ export const EditExceptionModal = memo(function EditExceptionModal({
'rules'
);
const onError = useCallback(
(error) => {
const handleExceptionUpdateError = useCallback(
(error: Error, statusCode: number | null, message: string | null) => {
if (error.message.includes('Conflict')) {
setHasVersionConflict(true);
} else {
addError(error, { title: i18n.EDIT_EXCEPTION_ERROR });
onCancel();
setUpdateError({
reason: error.message,
code: statusCode,
details: message,
listListId: exceptionItem.list_id,
});
}
},
[setUpdateError, setHasVersionConflict, exceptionItem.list_id]
);
const handleDissasociationSuccess = useCallback(
(id: string): void => {
addSuccess(sharedI18n.DISSASOCIATE_LIST_SUCCESS(id));
if (onRuleChange) {
onRuleChange();
}
onCancel();
},
[addSuccess, onCancel, onRuleChange]
);
const handleDissasociationError = useCallback(
(error: Error): void => {
addError(error, { title: sharedI18n.DISSASOCIATE_EXCEPTION_LIST_ERROR });
onCancel();
},
[addError, onCancel]
);
const onSuccess = useCallback(() => {
const handleExceptionUpdateSuccess = useCallback((): void => {
addSuccess(i18n.EDIT_EXCEPTION_SUCCESS);
onConfirm();
}, [addSuccess, onConfirm]);
@ -127,8 +162,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({
const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException(
{
http,
onSuccess,
onError,
onSuccess: handleExceptionUpdateSuccess,
onError: handleExceptionUpdateError,
}
);
@ -222,11 +257,9 @@ export const EditExceptionModal = memo(function EditExceptionModal({
{ruleName}
</ModalHeaderSubtitle>
</ModalHeader>
{(addExceptionIsLoading || isIndexPatternLoading || isSignalIndexLoading) && (
<Loader data-test-subj="loadingEditExceptionModal" size="xl" />
)}
{!isSignalIndexLoading && !addExceptionIsLoading && !isIndexPatternLoading && (
<>
<ModalBodySection className="builder-section">
@ -280,7 +313,18 @@ export const EditExceptionModal = memo(function EditExceptionModal({
</ModalBodySection>
</>
)}
{updateError != null && (
<ModalBodySection>
<ErrorCallout
http={http}
errorInfo={updateError}
rule={maybeRule}
onCancel={onCancel}
onSuccess={handleDissasociationSuccess}
onError={handleDissasociationError}
/>
</ModalBodySection>
)}
{hasVersionConflict && (
<ModalBodySection>
<EuiCallOut title={i18n.VERSION_CONFLICT_ERROR_TITLE} color="danger" iconType="alert">
@ -288,20 +332,21 @@ export const EditExceptionModal = memo(function EditExceptionModal({
</EuiCallOut>
</ModalBodySection>
)}
{updateError == null && (
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{i18n.CANCEL}</EuiButtonEmpty>
<EuiButton
data-test-subj="edit-exception-confirm-button"
onClick={onEditExceptionConfirm}
isLoading={addExceptionIsLoading}
isDisabled={isSubmitButtonDisabled}
fill
>
{i18n.EDIT_EXCEPTION_SAVE_BUTTON}
</EuiButton>
</EuiModalFooter>
<EuiButton
data-test-subj="edit-exception-confirm-button"
onClick={onEditExceptionConfirm}
isLoading={addExceptionIsLoading}
isDisabled={isSubmitButtonDisabled}
fill
>
{i18n.EDIT_EXCEPTION_SAVE_BUTTON}
</EuiButton>
</EuiModalFooter>
)}
</Modal>
</EuiOverlayMask>
);

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { getListMock } from '../../../../common/detection_engine/schemas/types/lists.mock';
import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list';
import { createKibanaCoreStartMock } from '../../mock/kibana_core';
import { ErrorCallout } from './error_callout';
import { savedRuleMock } from '../../../detections/containers/detection_engine/rules/mock';
jest.mock('../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list');
const mockKibanaHttpService = createKibanaCoreStartMock().http;
describe('ErrorCallout', () => {
const mockDissasociate = jest.fn();
beforeEach(() => {
(useDissasociateExceptionList as jest.Mock).mockReturnValue([false, mockDissasociate]);
});
it('it renders error details', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ErrorCallout
http={mockKibanaHttpService}
errorInfo={{
reason: 'error reason',
code: 500,
details: null,
listListId: null,
}}
rule={{ ...savedRuleMock, exceptions_list: [getListMock()] }}
onCancel={jest.fn()}
onSuccess={jest.fn()}
onError={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text()
).toEqual('Error: error reason (500)');
expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual(
'Error fetching exception list'
);
});
it('it invokes "onCancel" when cancel button clicked', () => {
const mockOnCancel = jest.fn();
const wrapper = mountWithIntl(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ErrorCallout
http={mockKibanaHttpService}
errorInfo={{
reason: 'error reason',
code: 500,
details: null,
listListId: null,
}}
rule={{ ...savedRuleMock, exceptions_list: [getListMock()] }}
onCancel={mockOnCancel}
onSuccess={jest.fn()}
onError={jest.fn()}
/>
</ThemeProvider>
);
wrapper.find('[data-test-subj="errorCalloutCancelButton"]').at(0).simulate('click');
expect(mockOnCancel).toHaveBeenCalled();
});
it('it does not render status code if not available', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ErrorCallout
http={mockKibanaHttpService}
errorInfo={{
reason: 'not found',
code: null,
details: 'list of id "some_uuid" not found',
listListId: null,
}}
rule={{ ...savedRuleMock, exceptions_list: [getListMock()] }}
onCancel={jest.fn()}
onSuccess={jest.fn()}
onError={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text()
).toEqual('Error: not found');
expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual(
'Error fetching exception list'
);
expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeFalsy();
});
it('it renders specific missing exceptions list error', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ErrorCallout
http={mockKibanaHttpService}
errorInfo={{
reason: 'not found',
code: 404,
details: 'list of id "some_uuid" not found',
listListId: null,
}}
rule={{ ...savedRuleMock, exceptions_list: [getListMock()] }}
onCancel={jest.fn()}
onSuccess={jest.fn()}
onError={jest.fn()}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="errorCalloutContainer"] .euiCallOutHeader__title').text()
).toEqual('Error: not found (404)');
expect(wrapper.find('[data-test-subj="errorCalloutMessage"]').at(0).text()).toEqual(
'The associated exception list (some_uuid) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.'
);
expect(wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').exists()).toBeTruthy();
});
it('it dissasociates list from rule when remove exception list clicked ', () => {
const wrapper = mountWithIntl(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<ErrorCallout
http={mockKibanaHttpService}
errorInfo={{
reason: 'not found',
code: 404,
details: 'list of id "some_uuid" not found',
listListId: null,
}}
rule={{ ...savedRuleMock, exceptions_list: [getListMock()] }}
onCancel={jest.fn()}
onSuccess={jest.fn()}
onError={jest.fn()}
/>
</ThemeProvider>
);
wrapper.find('[data-test-subj="errorCalloutDissasociateButton"]').at(0).simulate('click');
expect(mockDissasociate).toHaveBeenCalledWith([]);
});
});

View file

@ -0,0 +1,169 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useEffect, useState, useCallback } from 'react';
import {
EuiButtonEmpty,
EuiAccordion,
EuiCodeBlock,
EuiButton,
EuiCallOut,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { HttpSetup } from '../../../../../../../src/core/public';
import { List } from '../../../../common/detection_engine/schemas/types/lists';
import { Rule } from '../../../detections/containers/detection_engine/rules/types';
import * as i18n from './translations';
import { useDissasociateExceptionList } from '../../../detections/containers/detection_engine/rules/use_dissasociate_exception_list';
export interface ErrorInfo {
reason: string | null;
code: number | null;
details: string | null;
listListId: string | null;
}
export interface ErrorCalloutProps {
http: HttpSetup;
rule: Rule | null;
errorInfo: ErrorInfo;
onCancel: () => void;
onSuccess: (listId: string) => void;
onError: (arg: Error) => void;
}
const ErrorCalloutComponent = ({
http,
rule,
errorInfo,
onCancel,
onError,
onSuccess,
}: ErrorCalloutProps): JSX.Element => {
const [listToDelete, setListToDelete] = useState<List | null>(null);
const [errorTitle, setErrorTitle] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>(i18n.ADD_EXCEPTION_FETCH_ERROR);
const handleOnSuccess = useCallback((): void => {
onSuccess(listToDelete != null ? listToDelete.id : '');
}, [onSuccess, listToDelete]);
const [isDissasociatingList, handleDissasociateExceptionList] = useDissasociateExceptionList({
http,
ruleRuleId: rule != null ? rule.rule_id : '',
onSuccess: handleOnSuccess,
onError,
});
const canDisplay404Actions = useMemo(
(): boolean =>
errorInfo.code === 404 &&
rule != null &&
listToDelete != null &&
handleDissasociateExceptionList != null,
[errorInfo.code, listToDelete, handleDissasociateExceptionList, rule]
);
useEffect((): void => {
// Yes, it's redundant, unfortunately typescript wasn't picking up
// that `listToDelete` is checked in canDisplay404Actions
if (canDisplay404Actions && listToDelete != null) {
setErrorMessage(i18n.ADD_EXCEPTION_FETCH_404_ERROR(listToDelete.id));
}
setErrorTitle(`${errorInfo.reason}${errorInfo.code != null ? ` (${errorInfo.code})` : ''}`);
}, [errorInfo.reason, errorInfo.code, listToDelete, canDisplay404Actions]);
const handleDissasociateList = useCallback((): void => {
// Yes, it's redundant, unfortunately typescript wasn't picking up
// that `handleDissasociateExceptionList` and `list` are checked in
// canDisplay404Actions
if (
canDisplay404Actions &&
rule != null &&
listToDelete != null &&
handleDissasociateExceptionList != null
) {
const exceptionLists = (rule.exceptions_list ?? []).filter(
({ id }) => id !== listToDelete.id
);
handleDissasociateExceptionList(exceptionLists);
}
}, [handleDissasociateExceptionList, listToDelete, canDisplay404Actions, rule]);
useEffect((): void => {
if (errorInfo.code === 404 && rule != null && rule.exceptions_list != null) {
const [listFound] = rule.exceptions_list.filter(
({ id, list_id: listId }) =>
(errorInfo.details != null && errorInfo.details.includes(id)) ||
errorInfo.listListId === listId
);
setListToDelete(listFound);
}
}, [rule, errorInfo.details, errorInfo.code, errorInfo.listListId]);
return (
<EuiCallOut
data-test-subj="errorCalloutContainer"
title={`${i18n.ERROR}: ${errorTitle}`}
color="danger"
iconType="alert"
>
<EuiText size="s">
<p data-test-subj="errorCalloutMessage">{errorMessage}</p>
</EuiText>
<EuiSpacer />
{listToDelete != null && (
<EuiAccordion
id="accordion1"
buttonContent={
<EuiText size="s">
<p>{i18n.MODAL_ERROR_ACCORDION_TEXT}</p>
</EuiText>
}
>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="m"
overflowHeight={300}
isCopyable
>
{JSON.stringify(listToDelete)}
</EuiCodeBlock>
</EuiAccordion>
)}
<EuiSpacer />
<EuiButtonEmpty
data-test-subj="errorCalloutCancelButton"
color="danger"
isDisabled={isDissasociatingList}
onClick={onCancel}
>
{i18n.CANCEL}
</EuiButtonEmpty>
{canDisplay404Actions && (
<EuiButton
data-test-subj="errorCalloutDissasociateButton"
isLoading={isDissasociatingList}
onClick={handleDissasociateList}
color="danger"
>
{i18n.CLEAR_EXCEPTIONS_LABEL}
</EuiButton>
)}
</EuiCallOut>
);
};
ErrorCalloutComponent.displayName = 'ErrorCalloutComponent';
export const ErrorCallout = React.memo(ErrorCalloutComponent);
ErrorCallout.displayName = 'ErrorCallout';

View file

@ -190,3 +190,52 @@ export const TOTAL_ITEMS_FETCH_ERROR = i18n.translate(
defaultMessage: 'Error getting exception item totals',
}
);
export const CLEAR_EXCEPTIONS_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.clearExceptionsLabel',
{
defaultMessage: 'Remove Exception List',
}
);
export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) =>
i18n.translate('xpack.securitySolution.exceptions.fetch404Error', {
values: { listId },
defaultMessage:
'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.',
});
export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate(
'xpack.securitySolution.exceptions.fetchError',
{
defaultMessage: 'Error fetching exception list',
}
);
export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', {
defaultMessage: 'Error',
});
export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', {
defaultMessage: 'Cancel',
});
export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate(
'xpack.securitySolution.exceptions.modalErrorAccordionText',
{
defaultMessage: 'Show rule reference information:',
}
);
export const DISSASOCIATE_LIST_SUCCESS = (id: string) =>
i18n.translate('xpack.securitySolution.exceptions.dissasociateListSuccessText', {
values: { id },
defaultMessage: 'Exception list ({id}) has successfully been removed',
});
export const DISSASOCIATE_EXCEPTION_LIST_ERROR = i18n.translate(
'xpack.securitySolution.exceptions.dissasociateExceptionListError',
{
defaultMessage: 'Failed to remove exception list',
}
);

View file

@ -148,6 +148,50 @@ describe('useAddOrUpdateException', () => {
});
});
it('invokes "onError" if call to add exception item fails', async () => {
const mockError = new Error('error adding item');
addExceptionListItem = jest
.spyOn(listsApi, 'addExceptionListItem')
.mockRejectedValue(mockError);
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(onError).toHaveBeenCalledWith(mockError, null, null);
});
});
it('invokes "onError" if call to update exception item fails', async () => {
const mockError = new Error('error updating item');
updateExceptionListItem = jest
.spyOn(listsApi, 'updateExceptionListItem')
.mockRejectedValue(mockError);
await act(async () => {
const { rerender, result, waitForNextUpdate } = render();
const addOrUpdateItems = await waitForAddOrUpdateFunc({
rerender,
result,
waitForNextUpdate,
});
if (addOrUpdateItems) {
addOrUpdateItems(...addOrUpdateItemsArgs);
}
await waitForNextUpdate();
expect(onError).toHaveBeenCalledWith(mockError, null, null);
});
});
describe('when alertIdToClose is not passed in', () => {
it('should not update the alert status', async () => {
await act(async () => {

View file

@ -42,7 +42,7 @@ export type ReturnUseAddOrUpdateException = [
export interface UseAddOrUpdateExceptionProps {
http: HttpStart;
onError: (arg: Error) => void;
onError: (arg: Error, code: number | null, message: string | null) => void;
onSuccess: () => void;
}
@ -157,7 +157,11 @@ export const useAddOrUpdateException = ({
} catch (error) {
if (isSubscribed) {
setIsLoading(false);
onError(error);
if (error.body != null) {
onError(error, error.body.status_code, error.body.message);
} else {
onError(error, null, null);
}
}
}
};

View file

@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => {
await waitForNextUpdate();
await waitForNextUpdate();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(error);
expect(onError).toHaveBeenCalledWith(error, null, null);
});
});

View file

@ -30,7 +30,7 @@ export interface UseFetchOrCreateRuleExceptionListProps {
http: HttpStart;
ruleId: Rule['id'];
exceptionListType: ExceptionListSchema['type'];
onError: (arg: Error) => void;
onError: (arg: Error, code: number | null, message: string | null) => void;
onSuccess?: (ruleWasChanged: boolean) => void;
}
@ -179,7 +179,11 @@ export const useFetchOrCreateRuleExceptionList = ({
if (isSubscribed) {
setIsLoading(false);
setExceptionList(null);
onError(error);
if (error.body != null) {
onError(error, error.body.status_code, error.body.message);
} else {
onError(error, null, null);
}
}
}
}

View file

@ -322,11 +322,13 @@ const ExceptionsViewerComponent = ({
exceptionListTypeToEdit != null && (
<EditExceptionModal
ruleName={ruleName}
ruleId={ruleId}
ruleIndices={ruleIndices}
exceptionListType={exceptionListTypeToEdit}
exceptionItem={exceptionToEdit}
onCancel={handleOnCancelExceptionModal}
onConfirm={handleOnConfirmExceptionModal}
onRuleChange={onRuleChange}
/>
)}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { createKibanaCoreStartMock } from '../../../../common/mock/kibana_core';
import * as api from './api';
import { ruleMock } from './mock';
import {
ReturnUseDissasociateExceptionList,
UseDissasociateExceptionListProps,
useDissasociateExceptionList,
} from './use_dissasociate_exception_list';
const mockKibanaHttpService = createKibanaCoreStartMock().http;
describe('useDissasociateExceptionList', () => {
const onError = jest.fn();
const onSuccess = jest.fn();
beforeEach(() => {
jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock);
});
afterEach(() => {
jest.clearAllMocks();
});
test('initializes hook', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseDissasociateExceptionListProps,
ReturnUseDissasociateExceptionList
>(() =>
useDissasociateExceptionList({
http: mockKibanaHttpService,
ruleRuleId: 'rule_id',
onError,
onSuccess,
})
);
await waitForNextUpdate();
expect(result.current).toEqual([false, null]);
});
});
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState, useRef } from 'react';
import { HttpStart } from '../../../../../../../../src/core/public';
import { List } from '../../../../../common/detection_engine/schemas/types/lists';
import { patchRule } from './api';
type Func = (lists: List[]) => void;
export type ReturnUseDissasociateExceptionList = [boolean, Func | null];
export interface UseDissasociateExceptionListProps {
http: HttpStart;
ruleRuleId: string;
onError: (arg: Error) => void;
onSuccess: () => void;
}
/**
* Hook for removing an exception list reference from a rule
*
* @param http Kibana http service
* @param ruleRuleId a rule_id (NOT id)
* @param onError error callback
* @param onSuccess success callback
*
*/
export const useDissasociateExceptionList = ({
http,
ruleRuleId,
onError,
onSuccess,
}: UseDissasociateExceptionListProps): ReturnUseDissasociateExceptionList => {
const [isLoading, setLoading] = useState(false);
const dissasociateList = useRef<Func | null>(null);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const dissasociateListFromRule = (id: string) => async (
exceptionLists: List[]
): Promise<void> => {
try {
if (isSubscribed) {
setLoading(true);
await patchRule({
ruleProperties: {
rule_id: id,
exceptions_list: exceptionLists,
},
signal: abortCtrl.signal,
});
onSuccess();
setLoading(false);
}
} catch (err) {
if (isSubscribed) {
setLoading(false);
onError(err);
}
}
};
dissasociateList.current = dissasociateListFromRule(ruleRuleId);
return (): void => {
isSubscribed = false;
abortCtrl.abort();
};
}, [http, ruleRuleId, onError, onSuccess]);
return [isLoading, dissasociateList.current];
};