Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[7.8] [SIEM][CASE] Persist callout when dismissed (#68372) #70150

Merged
merged 1 commit into from
Jun 27, 2020
Merged
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
3 changes: 2 additions & 1 deletion x-pack/plugins/siem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"cypress:run-as-ci": "node ../../../scripts/functional_tests --config ../../test/siem_cypress/config.ts"
},
"devDependencies": {
"@types/lodash": "^4.14.110"
"@types/lodash": "^4.14.110",
"@types/md5": "^2.2.0"
},
"dependencies": {
"lodash": "^4.17.15",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
'xpack.siem.timeline.callOut.unauthorized.message.description',
{
defaultMessage:
'You require permission to auto-save timelines within the SIEM application, though you may continue to use the timeline to search and filter security events',
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.',
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { useKibana } from '../../lib/kibana';
import { createUseKibanaMock } from '../../mock/kibana_react';
import { useMessagesStorage, UseMessagesStorage } from './use_messages_storage';

jest.mock('../../lib/kibana');
const useKibanaMock = useKibana as jest.Mock;

describe('useLocalStorage', () => {
beforeEach(() => {
const services = { ...createUseKibanaMock()().services };
useKibanaMock.mockImplementation(() => ({ services }));
services.storage.store.clear();
});

it('should return an empty array when there is no messages', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages } = result.current;
expect(getMessages('case')).toEqual([]);
});
});

it('should add a message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages, addMessage } = result.current;
addMessage('case', 'id-1');
expect(getMessages('case')).toEqual(['id-1']);
});
});

it('should add multiple messages', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages, addMessage } = result.current;
addMessage('case', 'id-1');
addMessage('case', 'id-2');
expect(getMessages('case')).toEqual(['id-1', 'id-2']);
});
});

it('should remove a message', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages, addMessage, removeMessage } = result.current;
addMessage('case', 'id-1');
addMessage('case', 'id-2');
removeMessage('case', 'id-2');
expect(getMessages('case')).toEqual(['id-1']);
});
});

it('should clear all messages', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
useMessagesStorage()
);
await waitForNextUpdate();
const { getMessages, addMessage, clearAllMessages } = result.current;
addMessage('case', 'id-1');
addMessage('case', 'id-2');
clearAllMessages('case');
expect(getMessages('case')).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
@@ -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 { useCallback } from 'react';
import { useKibana } from '../../lib/kibana';

export interface UseMessagesStorage {
getMessages: (plugin: string) => string[];
addMessage: (plugin: string, id: string) => void;
removeMessage: (plugin: string, id: string) => void;
clearAllMessages: (plugin: string) => void;
}

export const useMessagesStorage = (): UseMessagesStorage => {
const { storage } = useKibana().services;

const getMessages = useCallback(
(plugin: string): string[] => storage.get(`${plugin}-messages`) ?? [],
[storage]
);

const addMessage = useCallback(
(plugin: string, id: string) => {
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
storage.set(`${plugin}-messages`, [...pluginStorage, id]);
},
[storage]
);

const removeMessage = useCallback(
(plugin: string, id: string) => {
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
storage.set(`${plugin}-messages`, [...pluginStorage.filter((val: string) => val !== id)]);
},
[storage]
);

const clearAllMessages = useCallback(
(plugin: string): string[] => storage.remove(`${plugin}-messages`),
[storage]
);

return {
getMessages,
addMessage,
clearAllMessages,
removeMessage,
};
};
3 changes: 3 additions & 0 deletions x-pack/plugins/siem/public/mock/kibana_react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
DEFAULT_INDEX_PATTERN,
} from '../../common/constants';
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
import { createSIEMStorageMock } from './mock_local_storage';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const mockUiSettings: Record<string, any> = {
Expand Down Expand Up @@ -74,6 +75,7 @@ export const createUseKibanaMock = () => {
const core = createKibanaCoreStartMock();
const plugins = createKibanaPluginsStartMock();
const useUiSetting = createUseUiSettingMock();
const { storage } = createSIEMStorageMock();

const services = {
...core,
Expand All @@ -82,6 +84,7 @@ export const createUseKibanaMock = () => {
...core.uiSettings,
get: useUiSetting,
},
storage,
};

return () => ({ services });
Expand Down
34 changes: 34 additions & 0 deletions x-pack/plugins/siem/public/mock/mock_local_storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 { IStorage, Storage } from '../../../../../src/plugins/kibana_utils/public';

export const localStorageMock = (): IStorage => {
let store: Record<string, unknown> = {};

return {
getItem: (key: string) => {
return store[key] || null;
},
setItem: (key: string, value: unknown) => {
store[key] = value;
},
clear() {
store = {};
},
removeItem(key: string) {
delete store[key];
},
};
};

export const createSIEMStorageMock = () => {
const localStorage = localStorageMock();
return {
localStorage,
storage: new Storage(localStorage),
};
};
6 changes: 3 additions & 3 deletions x-pack/plugins/siem/public/pages/case/case.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useGetUserSavedObjectPermissions } from '../../lib/kibana';
import { SpyRoute } from '../../utils/route/spy_routes';
import { AllCases } from './components/all_cases';

import { savedObjectReadOnly, CaseCallOut } from './components/callout';
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from './components/callout';
import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions';

export const CasesPage = React.memo(() => {
Expand All @@ -22,8 +22,8 @@ export const CasesPage = React.memo(() => {
<WrapperPage>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnly.title}
message={savedObjectReadOnly.description}
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
/>
)}
<AllCases userCanCrud={userPermissions?.crud ?? false} />
Expand Down
6 changes: 3 additions & 3 deletions x-pack/plugins/siem/public/pages/case/case_details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { SpyRoute } from '../../utils/route/spy_routes';
import { getCaseUrl } from '../../components/link_to';
import { navTabs } from '../home/home_navigations';
import { CaseView } from './components/case_view';
import { savedObjectReadOnly, CaseCallOut } from './components/callout';
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from './components/callout';

export const CaseDetailsPage = React.memo(() => {
const userPermissions = useGetUserSavedObjectPermissions();
Expand All @@ -30,8 +30,8 @@ export const CaseDetailsPage = React.memo(() => {
<WrapperPage noPadding>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={savedObjectReadOnly.title}
message={savedObjectReadOnly.description}
title={savedObjectReadOnlyErrorMessage.title}
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
/>
)}
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 { mount } from 'enzyme';

import { CallOut, CallOutProps } from './callout';

describe('Callout', () => {
const defaultProps: CallOutProps = {
id: 'md5-hex',
type: 'primary',
title: 'a tittle',
messages: [
{
id: 'generic-error',
title: 'message-one',
description: <p>{'error'}</p>,
},
],
showCallOut: true,
handleDismissCallout: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('It renders the callout', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
});

it('hides the callout', () => {
const wrapper = mount(<CallOut {...defaultProps} showCallOut={false} />);
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeFalsy();
});

it('does not shows any messages when the list is empty', () => {
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy();
});

it('transform the button color correctly - primary', () => {
const wrapper = mount(<CallOut {...defaultProps} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--primary')).toBeTruthy();
});

it('transform the button color correctly - success', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'success'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--secondary')).toBeTruthy();
});

it('transform the button color correctly - warning', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'warning'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--warning')).toBeTruthy();
});

it('transform the button color correctly - danger', () => {
const wrapper = mount(<CallOut {...defaultProps} type={'danger'} />);
const className =
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
'';
expect(className.includes('euiButton--danger')).toBeTruthy();
});

it('dismiss the callout correctly', () => {
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
wrapper.update();

expect(defaultProps.handleDismissCallout).toHaveBeenCalledWith('md5-hex', 'primary');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* 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 { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import React, { memo, useCallback } from 'react';

import { ErrorMessage } from './types';
import * as i18n from './translations';

export interface CallOutProps {
id: string;
type: NonNullable<ErrorMessage['errorType']>;
title: string;
messages: ErrorMessage[];
showCallOut: boolean;
handleDismissCallout: (id: string, type: NonNullable<ErrorMessage['errorType']>) => void;
}

const CallOutComponent = ({
id,
type,
title,
messages,
showCallOut,
handleDismissCallout,
}: CallOutProps) => {
const handleCallOut = useCallback(() => handleDismissCallout(id, type), [
handleDismissCallout,
id,
type,
]);

return showCallOut ? (
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
{!isEmpty(messages) && (
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
)}
<EuiButton
data-test-subj={`callout-dismiss-${id}`}
color={type === 'success' ? 'secondary' : type}
onClick={handleCallOut}
>
{i18n.DISMISS_CALLOUT}
</EuiButton>
</EuiCallOut>
) : null;
};

export const CallOut = memo(CallOutComponent);
Loading