[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:
parent
4e1b1b5d9e
commit
b9c8201202
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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()}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -379,7 +379,7 @@ describe('useFetchOrCreateRuleExceptionList', () => {
|
|||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
expect(onError).toHaveBeenCalledWith(error, null, null);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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];
|
||||
};
|
Loading…
Reference in a new issue