[Security Solution] host isolation exceptions delete item UI (#113541)

Co-authored-by: David Sánchez <davidsansol92@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Esteban Beltran 2021-10-05 15:50:44 +02:00 committed by GitHub
parent a4f209e6f0
commit 9902cbdc07
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 512 additions and 9 deletions

View file

@ -64,3 +64,13 @@ export async function getHostIsolationExceptionItems({
});
return entries;
}
export async function deleteHostIsolationExceptionItems(http: HttpStart, id: string) {
await ensureHostIsolationExceptionsListExists(http);
return http.delete<ExceptionListItemSchema>(EXCEPTION_LIST_ITEM_URL, {
query: {
id,
namespace_type: 'agnostic',
},
});
}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { Action } from 'redux';
import { HostIsolationExceptionsPageState } from '../types';
@ -13,4 +14,19 @@ export type HostIsolationExceptionsPageDataChanged =
payload: HostIsolationExceptionsPageState['entries'];
};
export type HostIsolationExceptionsPageAction = HostIsolationExceptionsPageDataChanged;
export type HostIsolationExceptionsDeleteItem = Action<'hostIsolationExceptionsMarkToDelete'> & {
payload?: ExceptionListItemSchema;
};
export type HostIsolationExceptionsSubmitDelete = Action<'hostIsolationExceptionsSubmitDelete'>;
export type HostIsolationExceptionsDeleteStatusChanged =
Action<'hostIsolationExceptionsDeleteStatusChanged'> & {
payload: HostIsolationExceptionsPageState['deletion']['status'];
};
export type HostIsolationExceptionsPageAction =
| HostIsolationExceptionsPageDataChanged
| HostIsolationExceptionsDeleteItem
| HostIsolationExceptionsSubmitDelete
| HostIsolationExceptionsDeleteStatusChanged;

View file

@ -16,4 +16,8 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio
page_size: MANAGEMENT_DEFAULT_PAGE_SIZE,
filter: '',
},
deletion: {
item: undefined,
status: createUninitialisedResourceState(),
},
});

View file

@ -14,8 +14,12 @@ import {
createSpyMiddleware,
MiddlewareActionSpyHelper,
} from '../../../../common/store/test_utils';
import { isFailedResourceState, isLoadedResourceState } from '../../../state';
import { getHostIsolationExceptionItems } from '../service';
import {
isFailedResourceState,
isLoadedResourceState,
isLoadingResourceState,
} from '../../../state';
import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../service';
import { HostIsolationExceptionsPageState } from '../types';
import { initialHostIsolationExceptionsPageState } from './builders';
import { createHostIsolationExceptionsPageMiddleware } from './middleware';
@ -24,6 +28,7 @@ import { getListFetchError } from './selector';
jest.mock('../service');
const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock;
const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock;
const fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
@ -139,4 +144,69 @@ describe('Host isolation exceptions middleware', () => {
});
});
});
describe('When deleting an item from host isolation exceptions', () => {
beforeEach(() => {
deleteHostIsolationExceptionItemsMock.mockClear();
deleteHostIsolationExceptionItemsMock.mockReturnValue(undefined);
getHostIsolationExceptionItemsMock.mockClear();
getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock);
store.dispatch({
type: 'hostIsolationExceptionsMarkToDelete',
payload: {
id: '1',
},
});
});
it('should call the delete exception API when a delete is submitted and advertise a loading status', async () => {
const waiter = Promise.all([
// delete loading action
spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', {
validate({ payload }) {
return isLoadingResourceState(payload);
},
}),
// delete finished action
spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', {
validate({ payload }) {
return isLoadedResourceState(payload);
},
}),
]);
store.dispatch({
type: 'hostIsolationExceptionsSubmitDelete',
});
await waiter;
expect(deleteHostIsolationExceptionItemsMock).toHaveBeenLastCalledWith(
fakeCoreStart.http,
'1'
);
});
it('should dispatch a failure if the API returns an error', async () => {
deleteHostIsolationExceptionItemsMock.mockRejectedValue({
body: { message: 'error message', statusCode: 500, error: 'Internal Server Error' },
});
store.dispatch({
type: 'hostIsolationExceptionsSubmitDelete',
});
await spyMiddleware.waitForAction('hostIsolationExceptionsDeleteStatusChanged', {
validate({ payload }) {
return isFailedResourceState(payload);
},
});
});
it('should reload the host isolation exception lists after delete', async () => {
store.dispatch({
type: 'hostIsolationExceptionsSubmitDelete',
});
await spyMiddleware.waitForAction('hostIsolationExceptionsPageDataChanged', {
validate({ payload }) {
return isLoadingResourceState(payload);
},
});
});
});
});

View file

@ -5,8 +5,11 @@
* 2.0.
*/
import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { CoreStart, HttpStart } from 'kibana/public';
import {
ExceptionListItemSchema,
FoundExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { CoreStart, HttpSetup, HttpStart } from 'kibana/public';
import { matchPath } from 'react-router-dom';
import { AppLocation, Immutable } from '../../../../../common/endpoint/types';
import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store';
@ -17,9 +20,9 @@ import {
createFailedResourceState,
createLoadedResourceState,
} from '../../../state/async_resource_builders';
import { getHostIsolationExceptionItems } from '../service';
import { deleteHostIsolationExceptionItems, getHostIsolationExceptionItems } from '../service';
import { HostIsolationExceptionsPageState } from '../types';
import { getCurrentListPageDataState, getCurrentLocation } from './selector';
import { getCurrentListPageDataState, getCurrentLocation, getItemToDelete } from './selector';
export const SEARCHABLE_FIELDS: Readonly<string[]> = [`name`, `description`, `entries.value`];
@ -36,6 +39,9 @@ export const createHostIsolationExceptionsPageMiddleware = (
if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) {
loadHostIsolationExceptionsList(store, coreStart.http);
}
if (action.type === 'hostIsolationExceptionsSubmitDelete') {
deleteHostIsolationExceptionsItem(store, coreStart.http);
}
};
};
@ -88,3 +94,37 @@ function isHostIsolationExceptionsPage(location: Immutable<AppLocation>) {
}) !== null
);
}
async function deleteHostIsolationExceptionsItem(
store: ImmutableMiddlewareAPI<HostIsolationExceptionsPageState, AppAction>,
http: HttpSetup
) {
const { dispatch } = store;
const itemToDelete = getItemToDelete(store.getState());
if (itemToDelete === undefined) {
return;
}
try {
dispatch({
type: 'hostIsolationExceptionsDeleteStatusChanged',
payload: {
type: 'LoadingResourceState',
// @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830)
previousState: store.getState().deletion.status,
},
});
await deleteHostIsolationExceptionItems(http, itemToDelete.id);
dispatch({
type: 'hostIsolationExceptionsDeleteStatusChanged',
payload: createLoadedResourceState(itemToDelete),
});
loadHostIsolationExceptionsList(store, http);
} catch (error) {
dispatch({
type: 'hostIsolationExceptionsDeleteStatusChanged',
payload: createFailedResourceState<ExceptionListItemSchema>(error.body ?? error),
});
}
}

View file

@ -16,6 +16,7 @@ import { HostIsolationExceptionsPageState } from '../types';
import { initialHostIsolationExceptionsPageState } from './builders';
import { MANAGEMENT_ROUTING_HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../common/constants';
import { UserChangedUrl } from '../../../../common/store/routing/action';
import { createUninitialisedResourceState } from '../../../state';
type StateReducer = ImmutableReducer<HostIsolationExceptionsPageState, AppAction>;
type CaseReducer<T extends AppAction> = (
@ -45,6 +46,23 @@ export const hostIsolationExceptionsPageReducer: StateReducer = (
}
case 'userChangedUrl':
return userChangedUrl(state, action);
case 'hostIsolationExceptionsMarkToDelete': {
return {
...state,
deletion: {
item: action.payload,
status: createUninitialisedResourceState(),
},
};
}
case 'hostIsolationExceptionsDeleteStatusChanged':
return {
...state,
deletion: {
...state.deletion,
status: action.payload,
},
};
}
return state;
};

View file

@ -20,6 +20,7 @@ import {
import {
getLastLoadedResourceState,
isFailedResourceState,
isLoadedResourceState,
isLoadingResourceState,
} from '../../../state/async_resource_state';
import { HostIsolationExceptionsPageState } from '../types';
@ -73,3 +74,37 @@ export const getListFetchError: HostIsolationExceptionsSelector<
export const getCurrentLocation: HostIsolationExceptionsSelector<StoreState['location']> = (
state
) => state.location;
export const getDeletionState: HostIsolationExceptionsSelector<StoreState['deletion']> =
createSelector(getCurrentListPageState, (listState) => listState.deletion);
export const showDeleteModal: HostIsolationExceptionsSelector<boolean> = createSelector(
getDeletionState,
({ item }) => {
return Boolean(item);
}
);
export const getItemToDelete: HostIsolationExceptionsSelector<StoreState['deletion']['item']> =
createSelector(getDeletionState, ({ item }) => item);
export const isDeletionInProgress: HostIsolationExceptionsSelector<boolean> = createSelector(
getDeletionState,
({ status }) => {
return isLoadingResourceState(status);
}
);
export const wasDeletionSuccessful: HostIsolationExceptionsSelector<boolean> = createSelector(
getDeletionState,
({ status }) => {
return isLoadedResourceState(status);
}
);
export const getDeleteError: HostIsolationExceptionsSelector<ServerApiError | undefined> =
createSelector(getDeletionState, ({ status }) => {
if (isFailedResourceState(status)) {
return status.error;
}
});

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import type {
ExceptionListItemSchema,
FoundExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { AsyncResourceState } from '../../state/async_resource_state';
export interface HostIsolationExceptionsPageLocation {
@ -20,4 +23,8 @@ export interface HostIsolationExceptionsPageLocation {
export interface HostIsolationExceptionsPageState {
entries: AsyncResourceState<FoundExceptionListItemSchema>;
location: HostIsolationExceptionsPageLocation;
deletion: {
item?: ExceptionListItemSchema;
status: AsyncResourceState<ExceptionListItemSchema>;
};
}

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { act } from '@testing-library/react';
import {
AppContextTestRender,
createAppRootMockRenderer,
} from '../../../../../common/mock/endpoint';
import { HostIsolationExceptionDeleteModal } from './delete_modal';
import { isFailedResourceState, isLoadedResourceState } from '../../../../state';
import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../../service';
import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { fireEvent } from '@testing-library/dom';
jest.mock('../../service');
const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock;
const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock;
describe('When on the host isolation exceptions delete modal', () => {
let render: () => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction'];
let coreStart: AppContextTestRender['coreStart'];
beforeEach(() => {
const itemToDelete = getExceptionListItemSchemaMock();
getHostIsolationExceptionItemsMock.mockReset();
deleteHostIsolationExceptionItemsMock.mockReset();
const mockedContext = createAppRootMockRenderer();
mockedContext.store.dispatch({
type: 'hostIsolationExceptionsMarkToDelete',
payload: itemToDelete,
});
render = () => (renderResult = mockedContext.render(<HostIsolationExceptionDeleteModal />));
waitForAction = mockedContext.middlewareSpy.waitForAction;
({ coreStart } = mockedContext);
});
it('should render the delete modal with the cancel and submit buttons', () => {
render();
expect(renderResult.getByTestId('hostIsolationExceptionsDeleteModalCancelButton')).toBeTruthy();
expect(
renderResult.getByTestId('hostIsolationExceptionsDeleteModalConfirmButton')
).toBeTruthy();
});
it('should disable the buttons when confirm is pressed and show loading', async () => {
render();
const submitButton = renderResult.baseElement.querySelector(
'[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]'
)! as HTMLButtonElement;
const cancelButton = renderResult.baseElement.querySelector(
'[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]'
)! as HTMLButtonElement;
act(() => {
fireEvent.click(submitButton);
});
expect(submitButton.disabled).toBe(true);
expect(cancelButton.disabled).toBe(true);
expect(submitButton.querySelector('.euiLoadingSpinner')).not.toBeNull();
});
it('should clear the item marked to delete when cancel is pressed', async () => {
render();
const cancelButton = renderResult.baseElement.querySelector(
'[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]'
)! as HTMLButtonElement;
const waiter = waitForAction('hostIsolationExceptionsMarkToDelete', {
validate: ({ payload }) => {
return payload === undefined;
},
});
act(() => {
fireEvent.click(cancelButton);
});
await waiter;
});
it('should show success toast after the delete is completed', async () => {
render();
const updateCompleted = waitForAction('hostIsolationExceptionsDeleteStatusChanged', {
validate(action) {
return isLoadedResourceState(action.payload);
},
});
const submitButton = renderResult.baseElement.querySelector(
'[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]'
)! as HTMLButtonElement;
await act(async () => {
fireEvent.click(submitButton);
await updateCompleted;
});
expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith(
'"some name" has been removed from the Host Isolation Exceptions list.'
);
});
it('should show error toast if error is encountered', async () => {
deleteHostIsolationExceptionItemsMock.mockRejectedValue(
new Error("That's not true. That's impossible")
);
render();
const updateFailure = waitForAction('hostIsolationExceptionsDeleteStatusChanged', {
validate(action) {
return isFailedResourceState(action.payload);
},
});
const submitButton = renderResult.baseElement.querySelector(
'[data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"]'
)! as HTMLButtonElement;
await act(async () => {
fireEvent.click(submitButton);
await updateFailure;
});
expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith(
'Unable to remove "some name" from the Host Isolation Exceptions list. Reason: That\'s not true. That\'s impossible'
);
});
});

View file

@ -0,0 +1,141 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback, useEffect } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { i18n } from '@kbn/i18n';
import { useToasts } from '../../../../../common/lib/kibana';
import { useHostIsolationExceptionsSelector } from '../hooks';
import {
getDeleteError,
getItemToDelete,
isDeletionInProgress,
wasDeletionSuccessful,
} from '../../store/selector';
import { HostIsolationExceptionsPageAction } from '../../store/action';
export const HostIsolationExceptionDeleteModal = memo<{}>(() => {
const dispatch = useDispatch<Dispatch<HostIsolationExceptionsPageAction>>();
const toasts = useToasts();
const isDeleting = useHostIsolationExceptionsSelector(isDeletionInProgress);
const exception = useHostIsolationExceptionsSelector(getItemToDelete);
const wasDeleted = useHostIsolationExceptionsSelector(wasDeletionSuccessful);
const deleteError = useHostIsolationExceptionsSelector(getDeleteError);
const onCancel = useCallback(() => {
dispatch({ type: 'hostIsolationExceptionsMarkToDelete', payload: undefined });
}, [dispatch]);
const onConfirm = useCallback(() => {
dispatch({ type: 'hostIsolationExceptionsSubmitDelete' });
}, [dispatch]);
// Show toast for success
useEffect(() => {
if (wasDeleted) {
toasts.addSuccess(
i18n.translate(
'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteSuccess',
{
defaultMessage: '"{name}" has been removed from the Host Isolation Exceptions list.',
values: { name: exception?.name },
}
)
);
dispatch({ type: 'hostIsolationExceptionsMarkToDelete', payload: undefined });
}
}, [dispatch, exception?.name, toasts, wasDeleted]);
// show toast for failures
useEffect(() => {
if (deleteError) {
toasts.addDanger(
i18n.translate(
'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteFailure',
{
defaultMessage:
'Unable to remove "{name}" from the Host Isolation Exceptions list. Reason: {message}',
values: { name: exception?.name, message: deleteError.message },
}
)
);
}
}, [deleteError, exception?.name, toasts]);
return (
<EuiModal onClose={onCancel}>
<EuiModalHeader data-test-subj="hostIsolationExceptionsDeleteModalHeader">
<EuiModalHeaderTitle>
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.title"
defaultMessage="Delete Host Isolation Exception"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody data-test-subj="hostIsolationExceptionsFilterDeleteModalBody">
<EuiText>
<p>
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.subtitle"
defaultMessage='You are deleting exception "{name}".'
values={{ name: <b className="eui-textBreakWord">{exception?.name}</b> }}
/>
</p>
<p>
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.confirmation"
defaultMessage="This action cannot be undone. Are you sure you wish to continue?"
/>
</p>
</EuiText>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
onClick={onCancel}
isDisabled={isDeleting}
data-test-subj="hostIsolationExceptionsDeleteModalCancelButton"
>
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.cancel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
fill
color="danger"
onClick={onConfirm}
isLoading={isDeleting}
data-test-subj="hostIsolationExceptionsDeleteModalConfirmButton"
>
<FormattedMessage
id="xpack.securitySolution.hostIsolationExceptions.deletionDialog.confirmButton"
defaultMessage="Remove exception"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
});
HostIsolationExceptionDeleteModal.displayName = 'HostIsolationExceptionDeleteModal';

View file

@ -7,12 +7,14 @@
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { i18n } from '@kbn/i18n';
import React, { useCallback } from 'react';
import React, { Dispatch, useCallback } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { useDispatch } from 'react-redux';
import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item';
import {
getCurrentLocation,
getItemToDelete,
getListFetchError,
getListIsLoading,
getListItems,
@ -28,18 +30,29 @@ import { AdministrationListPage } from '../../../components/administration_list_
import { SearchExceptions } from '../../../components/search_exceptions';
import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/artifact_entry_card';
import { HostIsolationExceptionsEmptyState } from './components/empty';
import { HostIsolationExceptionsPageAction } from '../store/action';
import { HostIsolationExceptionDeleteModal } from './components/delete_modal';
type HostIsolationExceptionPaginatedContent = PaginatedContentProps<
Immutable<ExceptionListItemSchema>,
typeof ExceptionItem
>;
const DELETE_HOST_ISOLATION_EXCEPTION_LABEL = i18n.translate(
'xpack.securitySolution.hostIsolationExceptions.list.actions.delete',
{
defaultMessage: 'Delete Exception',
}
);
export const HostIsolationExceptionsList = () => {
const listItems = useHostIsolationExceptionsSelector(getListItems);
const pagination = useHostIsolationExceptionsSelector(getListPagination);
const isLoading = useHostIsolationExceptionsSelector(getListIsLoading);
const fetchError = useHostIsolationExceptionsSelector(getListFetchError);
const location = useHostIsolationExceptionsSelector(getCurrentLocation);
const dispatch = useDispatch<Dispatch<HostIsolationExceptionsPageAction>>();
const itemToDelete = useHostIsolationExceptionsSelector(getItemToDelete);
const navigateCallback = useHostIsolationExceptionsNavigateCallback();
@ -53,6 +66,19 @@ export const HostIsolationExceptionsList = () => {
const handleItemComponentProps = (element: ExceptionListItemSchema): ArtifactEntryCardProps => ({
item: element,
'data-test-subj': `hostIsolationExceptionsCard`,
actions: [
{
icon: 'trash',
onClick: () => {
dispatch({
type: 'hostIsolationExceptionsMarkToDelete',
payload: element,
});
},
'data-test-subj': 'deleteHostIsolationException',
children: DELETE_HOST_ISOLATION_EXCEPTION_LABEL,
},
],
});
const handlePaginatedContentChange: HostIsolationExceptionPaginatedContent['onChange'] =
@ -87,6 +113,7 @@ export const HostIsolationExceptionsList = () => {
)}
/>
<EuiSpacer size="l" />
{itemToDelete ? <HostIsolationExceptionDeleteModal /> : null}
<PaginatedContent<ExceptionListItemSchema, typeof ArtifactEntryCard>
items={listItems}
ItemComponent={ArtifactEntryCard}