[Security Solution] add chat assistant button to expandable flyout (#159633)

This commit is contained in:
Philippe Oberti 2023-06-14 19:11:53 -05:00 committed by GitHub
parent 7e067ec478
commit f1db0cd851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 252 additions and 30 deletions

View file

@ -38,6 +38,7 @@ import {
DOCUMENT_DETAILS_FLYOUT_FOOTER_MARK_AS_CLOSED,
DOCUMENT_DETAILS_FLYOUT_FOOTER_RESPOND,
DOCUMENT_DETAILS_FLYOUT_FOOTER_TAKE_ACTION_BUTTON,
DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON,
DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE,
DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE_VALUE,
DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY,
@ -65,7 +66,7 @@ import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
describe(
'Alert details expandable flyout right panel',
{ env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled'] } } },
{ env: { ftrConfig: { enableExperimental: ['securityFlyoutEnabled', 'assistantEnabled'] } } },
() => {
const rule = getNewRule();
@ -82,7 +83,7 @@ describe(
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_TITLE).should('be.visible').and('have.text', rule.name);
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE).should('be.visible');
cy.get(DOCUMENT_DETAILS_FLYOUT_HEADER_RISK_SCORE_VALUE)

View file

@ -15,6 +15,7 @@ import {
import {
COLLAPSE_DETAILS_BUTTON_TEST_ID,
EXPAND_DETAILS_BUTTON_TEST_ID,
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID,
FLYOUT_HEADER_RISK_SCORE_TITLE_TEST_ID,
FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID,
FLYOUT_HEADER_SEVERITY_TITLE_TEST_ID,
@ -53,6 +54,9 @@ export const DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY = getDataTestSubjectSelecto
export const DOCUMENT_DETAILS_FLYOUT_HEADER_SEVERITY_VALUE = getDataTestSubjectSelector(
FLYOUT_HEADER_SEVERITY_VALUE_TEST_ID
);
export const DOCUMENT_DETAILS_FLYOUT_HEADER_CHAT_BUTTON = getDataTestSubjectSelector(
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID
);
/* Footer */

View file

@ -9,6 +9,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { RightPanelContext } from '../context';
import {
FLYOUT_HEADER_CHAT_BUTTON_TEST_ID,
FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID,
FLYOUT_HEADER_SEVERITY_TITLE_TEST_ID,
FLYOUT_HEADER_SHARE_BUTTON_TEST_ID,
@ -19,19 +20,44 @@ import { DOCUMENT_DETAILS } from './translations';
import moment from 'moment-timezone';
import { useDateFormat, useTimeZone } from '../../../common/lib/kibana';
import { mockDataFormattedForFieldBrowser, mockGetFieldsData } from '../mocks/mock_context';
import { AssistantProvider } from '@kbn/elastic-assistant';
import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { useAssistant } from '../hooks/use_assistant';
jest.mock('../../../common/lib/kibana');
jest.mock('../hooks/use_assistant');
moment.suppressDeprecationWarnings = true;
moment.tz.setDefault('UTC');
const mockUseDateFormat = useDateFormat as jest.Mock;
const mockUseTimeZone = useTimeZone as jest.Mock;
const dateFormat = 'MMM D, YYYY @ HH:mm:ss.SSS';
const actionTypeRegistry = actionTypeRegistryMock.create();
const mockGetInitialConversations = jest.fn(() => ({}));
const mockGetComments = jest.fn(() => []);
const mockHttp = httpServiceMock.createStartContract({ basePath: '/test' });
const renderHeader = (contextValue: RightPanelContext) =>
render(
<AssistantProvider
actionTypeRegistry={actionTypeRegistry}
augmentMessageCodeBlocks={jest.fn()}
getComments={mockGetComments}
getInitialConversations={mockGetInitialConversations}
setConversations={jest.fn()}
http={mockHttp}
>
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
</AssistantProvider>
);
describe('<HeaderTitle />', () => {
beforeEach(() => {
mockUseDateFormat.mockImplementation(() => dateFormat);
mockUseTimeZone.mockImplementation(() => 'UTC');
jest.mocked(useDateFormat).mockImplementation(() => dateFormat);
jest.mocked(useTimeZone).mockImplementation(() => 'UTC');
jest.mocked(useAssistant).mockReturnValue({ showAssistant: true, promptContextId: '' });
});
it('should render mitre attack information', () => {
@ -40,11 +66,7 @@ describe('<HeaderTitle />', () => {
getFieldsData: jest.fn().mockImplementation(mockGetFieldsData),
} as unknown as RightPanelContext;
const { getByTestId } = render(
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
);
const { getByTestId } = renderHeader(contextValue);
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID)).toBeInTheDocument();
@ -72,16 +94,12 @@ describe('<HeaderTitle />', () => {
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { getByTestId } = render(
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
);
const { getByTestId } = renderHeader(contextValue);
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent('test');
});
it('should render share button in the title if document is an alert', () => {
it('should render share button in the title if document is an alert with url info', () => {
const contextValue = {
dataFormattedForFieldBrowser: [
{
@ -102,15 +120,53 @@ describe('<HeaderTitle />', () => {
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { getByTestId } = render(
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
);
const { getByTestId } = renderHeader(contextValue);
expect(getByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should not render share button in the title if alert is missing url info', () => {
const contextValue = {
dataFormattedForFieldBrowser: [
{
category: 'kibana',
field: 'kibana.alert.rule.uuid',
values: ['123'],
originalValue: ['123'],
isObjectArray: false,
},
],
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { queryByTestId } = renderHeader(contextValue);
expect(queryByTestId(FLYOUT_HEADER_SHARE_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
it('should render chat button in the title', () => {
const contextValue = {
dataFormattedForFieldBrowser: [],
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { getByTestId } = renderHeader(contextValue);
expect(getByTestId(FLYOUT_HEADER_CHAT_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should not render chat button in the title if should not be shown', () => {
jest.mocked(useAssistant).mockReturnValue({ showAssistant: false, promptContextId: '' });
const contextValue = {
dataFormattedForFieldBrowser: [],
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { queryByTestId } = renderHeader(contextValue);
expect(queryByTestId(FLYOUT_HEADER_CHAT_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
it('should render default document detail title if document is not an alert', () => {
const contextValue = {
dataFormattedForFieldBrowser: [
@ -125,11 +181,7 @@ describe('<HeaderTitle />', () => {
getFieldsData: () => [],
} as unknown as RightPanelContext;
const { getByTestId } = render(
<RightPanelContext.Provider value={contextValue}>
<HeaderTitle />
</RightPanelContext.Provider>
);
const { getByTestId } = renderHeader(contextValue);
expect(getByTestId(FLYOUT_HEADER_TITLE_TEST_ID)).toHaveTextContent(DOCUMENT_DETAILS);
});

View file

@ -7,8 +7,14 @@
import type { FC } from 'react';
import React, { memo } from 'react';
import { NewChatById } from '@kbn/elastic-assistant';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
import { isEmpty } from 'lodash';
import { useAssistant } from '../hooks/use_assistant';
import {
ALERT_SUMMARY_CONVERSATION_ID,
EVENT_SUMMARY_CONVERSATION_ID,
} from '../../../common/components/event_details/translations';
import { DocumentSeverity } from './severity';
import { RiskScore } from './risk_score';
import { DOCUMENT_DETAILS } from './translations';
@ -26,16 +32,38 @@ export const HeaderTitle: FC = memo(() => {
const { isAlert, ruleName, timestamp, alertUrl } = useBasicDataFromDetailsData(
dataFormattedForFieldBrowser
);
const { showAssistant, promptContextId } = useAssistant({
dataFormattedForFieldBrowser,
isAlert,
});
return (
<>
<EuiTitle size="s" data-test-subj={FLYOUT_HEADER_TITLE_TEST_ID}>
<EuiTitle size="s">
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<h4>{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}</h4>
<h4 data-test-subj={FLYOUT_HEADER_TITLE_TEST_ID}>
{isAlert && !isEmpty(ruleName) ? ruleName : DOCUMENT_DETAILS}
</h4>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isAlert && alertUrl && <ShareButton alertUrl={alertUrl} />}
<EuiFlexGroup alignItems="center">
{isAlert && alertUrl && (
<EuiFlexItem>
<ShareButton alertUrl={alertUrl} />
</EuiFlexItem>
)}
{showAssistant && (
<EuiFlexItem grow={false}>
<NewChatById
conversationId={
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID
}
promptContextId={promptContextId}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>

View file

@ -23,6 +23,7 @@ export const FLYOUT_HEADER_RISK_SCORE_VALUE_TEST_ID =
'securitySolutionAlertDetailsFlyoutHeaderRiskScoreValue';
export const FLYOUT_HEADER_SHARE_BUTTON_TEST_ID =
'securitySolutionAlertDetailsFlyoutHeaderShareButton';
export const FLYOUT_HEADER_CHAT_BUTTON_TEST_ID = 'newChatById';
/* Description section */

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import type { UseAssistantParams, UseAssistantResult } from './use_assistant';
import { useAssistant } from './use_assistant';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
jest.mock('../../../common/hooks/use_experimental_features');
jest.mock('@kbn/elastic-assistant');
const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser;
const isAlert = true;
const renderUseAssistant = () =>
renderHook((props: UseAssistantParams) => useAssistant(props), {
initialProps: { dataFormattedForFieldBrowser, isAlert },
});
describe('useAssistant', () => {
let hookResult: RenderHookResult<UseAssistantParams, UseAssistantResult>;
it(`should return showAssistant true and a value for promptContextId`, () => {
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(true);
jest
.mocked(useAssistantOverlay)
.mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' });
hookResult = renderUseAssistant();
expect(hookResult.result.current.showAssistant).toEqual(true);
expect(hookResult.result.current.promptContextId).toEqual('123');
});
it(`should return showAssistant false if feature flag is off`, () => {
jest.mocked(useIsExperimentalFeatureEnabled).mockReturnValue(false);
jest
.mocked(useAssistantOverlay)
.mockReturnValue({ showAssistantOverlay: jest.fn, promptContextId: '123' });
hookResult = renderUseAssistant();
expect(hookResult.result.current.showAssistant).toEqual(false);
expect(hookResult.result.current.promptContextId).toEqual('');
});
});

View file

@ -0,0 +1,83 @@
/*
* 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 type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { useAssistantOverlay } from '@kbn/elastic-assistant';
import { useCallback } from 'react';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { getPromptContextFromEventDetailsItem } from '../../../assistant/helpers';
import {
ALERT_SUMMARY_CONTEXT_DESCRIPTION,
ALERT_SUMMARY_CONVERSATION_ID,
ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
EVENT_SUMMARY_CONTEXT_DESCRIPTION,
EVENT_SUMMARY_CONVERSATION_ID,
EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP,
SUMMARY_VIEW,
} from '../../../common/components/event_details/translations';
import {
PROMPT_CONTEXT_ALERT_CATEGORY,
PROMPT_CONTEXT_EVENT_CATEGORY,
PROMPT_CONTEXTS,
} from '../../../assistant/content/prompt_contexts';
const useAssistantNoop = () => ({ promptContextId: undefined });
export interface UseAssistantParams {
/**
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
/**
* Is true if the document is an alert
*/
isAlert: boolean;
}
export interface UseAssistantResult {
/**
* Returns true if the assistant button is visible
*/
showAssistant: boolean;
/**
* Unique identifier for prompt context
*/
promptContextId: string;
}
/**
* Hook to return the assistant button visibility and prompt context id
*/
export const useAssistant = ({
dataFormattedForFieldBrowser,
isAlert,
}: UseAssistantParams): UseAssistantResult => {
const isAssistantEnabled = useIsExperimentalFeatureEnabled('assistantEnabled');
const useAssistantHook = isAssistantEnabled ? useAssistantOverlay : useAssistantNoop;
const getPromptContext = useCallback(
async () => getPromptContextFromEventDetailsItem(dataFormattedForFieldBrowser ?? []),
[dataFormattedForFieldBrowser]
);
const { promptContextId } = useAssistantHook(
isAlert ? 'alert' : 'event',
isAlert ? ALERT_SUMMARY_CONVERSATION_ID : EVENT_SUMMARY_CONVERSATION_ID,
isAlert
? ALERT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW)
: EVENT_SUMMARY_CONTEXT_DESCRIPTION(SUMMARY_VIEW),
getPromptContext,
null,
isAlert
? PROMPT_CONTEXTS[PROMPT_CONTEXT_ALERT_CATEGORY].suggestedUserPrompt
: PROMPT_CONTEXTS[PROMPT_CONTEXT_EVENT_CATEGORY].suggestedUserPrompt,
isAlert ? ALERT_SUMMARY_VIEW_CONTEXT_TOOLTIP : EVENT_SUMMARY_VIEW_CONTEXT_TOOLTIP
);
return {
showAssistant: isAssistantEnabled && promptContextId !== null,
promptContextId: promptContextId || '',
};
};