From e0bc286a758032f9f3127a0d06566e6721e52eca Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Thu, 23 Feb 2023 15:58:38 -0500 Subject: [PATCH] [Security Solution][Endpoint] Additional test coverage for the Endpoint Console (#151504) ## Summary - Adds more tests for covering functionality provided by the Response console Address TestRail tests: - 1948002 - 1948003 - 1948004 - 1948005 - 1948007 - 1948008 --- .../mock/endpoint/app_context_render.tsx | 11 ++ .../console/components/command_list.test.tsx | 155 +++++++++++++++++ .../console/components/command_list.tsx | 144 +++++++++++----- .../components/console_header.test.tsx | 50 ++++++ .../console/components/console_header.tsx | 21 ++- .../handle_execute_command.test.tsx | 23 --- .../side_panel/side_panel_content_layout.tsx | 13 +- .../side_panel_content_manager.test.tsx | 49 ++++++ .../side_panel/side_panel_content_manager.tsx | 16 +- .../state_selectors/use_data_test_subj.ts | 14 +- .../management/components/console/mocks.tsx | 161 +++++++++++++++++- .../lib/console_commands_definition.ts | 2 +- .../console_commands_definition.test.tsx | 75 ++++++++ .../management/hooks/use_test_id_generator.ts | 7 +- 14 files changed, 662 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/command_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/console_header.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index cdccb9dc40ed..253c16a044f0 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -24,6 +24,7 @@ import type { } from '@testing-library/react-hooks/src/types/react'; import type { UseBaseQueryResult } from '@tanstack/react-query'; import ReactDOM from 'react-dom'; +import { ExperimentalFeaturesService } from '../../experimental_features_service'; import { applyIntersectionObserverMock } from '../intersection_observer_mock'; import { ConsoleManager } from '../../../management/components/console'; import type { StartPlugins, StartServices } from '../../../types'; @@ -42,6 +43,7 @@ import { APP_UI_ID, APP_PATH } from '../../../../common/constants'; import { KibanaContextProvider, KibanaServices } from '../../lib/kibana'; import { getDeepLinks } from '../../../app/deep_links'; import { fleetGetPackageHttpMock } from '../../../management/mocks'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; const REAL_REACT_DOM_CREATE_PORTAL = ReactDOM.createPortal; @@ -282,7 +284,16 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { return hookResult.current; }; + ExperimentalFeaturesService.init({ experimentalFeatures: allowedExperimentalValues }); + const setExperimentalFlag: AppContextTestRender['setExperimentalFlag'] = (flags) => { + ExperimentalFeaturesService.init({ + experimentalFeatures: { + ...allowedExperimentalValues, + ...flags, + }, + }); + store.dispatch({ type: UpdateExperimentalFeaturesTestActionType, payload: flags, diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.test.tsx new file mode 100644 index 000000000000..7696fe035f2a --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.test.tsx @@ -0,0 +1,155 @@ +/* + * 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 { ConsoleTestSetup, HelpSidePanelSelectorsAndActions } from '../mocks'; +import { + getCommandListMock, + getConsoleTestSetup, + getHelpSidePanelSelectorsAndActionsMock, +} from '../mocks'; +import React from 'react'; +import { waitFor } from '@testing-library/react'; + +describe('When rendering the command list (help output)', () => { + let render: ConsoleTestSetup['renderConsole']; + let renderResult: ReturnType; + let consoleSelectors: ConsoleTestSetup['selectors']; + let enterCommand: ConsoleTestSetup['enterCommand']; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + consoleSelectors = testSetup.selectors; + enterCommand = testSetup.enterCommand; + }); + + describe('and its displayed on the side panel', () => { + let renderAndOpenHelpPanel: typeof render; + let helpPanelSelectors: HelpSidePanelSelectorsAndActions; + + beforeEach(() => { + renderAndOpenHelpPanel = (props) => { + render(props); + helpPanelSelectors = getHelpSidePanelSelectorsAndActionsMock(renderResult); + consoleSelectors.openHelpPanel(); + + return renderResult; + }; + }); + + it('should display the help panel header', () => { + renderAndOpenHelpPanel(); + + expect(renderResult.getByTestId('test-sidePanel-header')).toHaveTextContent( + 'HelpUse the add () button to populate a response action to the text bar. Add ' + + 'additional parameters or comments as necessary.' + ); + }); + + it('should display the command list', () => { + renderAndOpenHelpPanel(); + + expect(renderResult.getByTestId('test-commandList')).toBeTruthy(); + }); + + it('should close the side panel when close button is clicked', () => { + renderAndOpenHelpPanel(); + consoleSelectors.closeHelpPanel(); + + expect(renderResult.queryByTestId('test-sidePanel')).toBeNull(); + }); + + it('should display helpful tips', () => { + renderAndOpenHelpPanel(); + + expect(renderResult.getByTestId('test-commandList-helpfulTips')).toHaveTextContent( + 'Helpful tips:You can enter consecutive response actions — no need to wait for previous ' + + 'actions to complete.Leaving the response console does not terminate any actions that have ' + + 'been submitted.Learn moreExternal link(opens in a new tab or window) about response actions ' + + 'and using the console.' + ); + + expect(renderResult.getByTestId('test-commandList-helpfulHintDocLink')).toBeTruthy(); + }); + + it('should display common commands and parameters section', () => { + renderAndOpenHelpPanel(); + + expect( + renderResult.getByTestId('test-commandList-Supportingcommandsparameters') + ).toBeTruthy(); + }); + + it('should group commands by group label', () => { + renderAndOpenHelpPanel(); + const groups = helpPanelSelectors.getHelpGroupLabels(); + + expect(groups).toEqual([ + 'group 1', + 'Supporting commands & parameters', + 'group 2', + 'Other commands', + ]); + }); + + it('should display the list of command in the expected order', () => { + renderAndOpenHelpPanel(); + const commands = helpPanelSelectors.getHelpCommandNames('group 1'); + + expect(commands).toEqual(['cmd6 --foo', 'cmd1']); + }); + + it('should hide command if command definition helpHidden is true', () => { + const commands = getCommandListMock(); + commands[0].helpHidden = true; + renderAndOpenHelpPanel({ commands }); + + expect(renderResult.queryByTestId('test-commandList-group1-cmd1')).toBeNull(); + }); + + it('should disable "add to text bar" button if command definition helpHidden is true', () => { + const commands = getCommandListMock(); + commands[0].helpDisabled = true; + renderAndOpenHelpPanel({ commands }); + + expect(renderResult.getByTestId('test-commandList-group1-cmd1-addToInput')).toBeDisabled(); + }); + + it('should add command to console input when [+] button is clicked', () => { + renderAndOpenHelpPanel(); + renderResult.getByTestId('test-commandList-group1-cmd6-addToInput').click(); + expect(consoleSelectors.getInputText()).toEqual('cmd6 --foo '); + }); + + it('should display custom help output when Command service has `getHelp()` defined', async () => { + const HelpComponent: React.FunctionComponent = () => { + return
{'help output'}
; + }; + render({ HelpComponent }); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + }); + + describe('And displayed when `help` command is entered', () => { + it('should display custom help output when Command service has `getHelp()` defined', async () => { + const HelpComponent: React.FunctionComponent = () => { + return
{'help output'}
; + }; + render({ HelpComponent }); + enterCommand('help'); + + await waitFor(() => { + expect(renderResult.getByTestId('custom-help')).toBeTruthy(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx index 6fd66bf94c47..e6323fccb847 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/command_list.tsx @@ -7,7 +7,6 @@ import React, { memo, useMemo, useCallback } from 'react'; import styled from 'styled-components'; -import { groupBy, sortBy } from 'lodash'; import { EuiBadge, EuiBasicTable, @@ -24,6 +23,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { sortBy } from 'lodash'; import type { CommandDefinition } from '../types'; import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; @@ -33,6 +33,21 @@ import { getCommandNameWithArgs } from '../service/utils'; import { ConsoleCodeBlock } from './console_code_block'; import { useKibana } from '../../../../common/lib/kibana'; +const otherCommandsGroupLabel = i18n.translate( + 'xpack.securitySolution.console.commandList.otherCommandsGroup.label', + { + defaultMessage: 'Other commands', + } +); + +/** + * Takes a string and removes all non-letters/number from it. + * @param value + */ +export const convertToTestId = (value: string): string => { + return value.replace(/[^A-Za-z0-9]/g, ''); +}; + // @ts-expect-error TS2769 const StyledEuiBasicTable = styled(EuiBasicTable)` margin-top: ${({ theme: { eui } }) => eui.euiSizeS}; @@ -78,12 +93,10 @@ export interface CommandListProps { } export const CommandList = memo(({ commands, display = 'default' }) => { - const getTestId = useTestIdGenerator(useDataTestSubj()); + const getTestId = useTestIdGenerator(useDataTestSubj('commandList')); const dispatch = useConsoleStateDispatch(); const { docLinks } = useKibana().services; - const allowedCommands = commands.filter((command) => command.helpHidden !== true); - const footerMessage = useMemo(() => { return ( (({ commands, display = 'defaul ); }, []); - const otherCommandsGroupLabel = i18n.translate( - 'xpack.securitySolution.console.commandList.otherCommandsGroup.label', - { - defaultMessage: 'Other commands', - } - ); - const updateInputText = useCallback( (text) => () => { dispatch({ @@ -136,23 +142,62 @@ export const CommandList = memo(({ commands, display = 'defaul ); const commandsByGroups = useMemo(() => { - return Object.values(groupBy(allowedCommands, 'helpGroupLabel')).reduce( - (acc, current) => { - if (current[0].helpGroupPosition !== undefined) { - // If it already exists just move it to the end - if (acc[current[0].helpGroupPosition]) { - acc[acc.length] = acc[current[0].helpGroupPosition]; - } + const helpGroups = new Map< + string, + { label: string; position: number; list: CommandDefinition[] } + >(); - acc[current[0].helpGroupPosition] = sortBy(current, 'helpCommandPosition'); - } else if (current.length) { - acc.push(sortBy(current, 'helpCommandPosition')); + // We only show commands that are no hidden + const allowedCommands = commands.filter((command) => command.helpHidden !== true); + + for (const allowedCommand of allowedCommands) { + const { helpGroupLabel = otherCommandsGroupLabel, helpGroupPosition = Infinity } = + allowedCommand; + + const groupEntry = helpGroups.get(helpGroupLabel); + + if (groupEntry) { + groupEntry.list.push(allowedCommand); + + // Its possible (but probably not intentionally) that the same Group Label might + // have different positions defined (ex. one has a position, and another does not, + // which defaults to `Infinity`. If we detect that here, then update the group + // position. In the end, the group label will have the last explicitly defined + // position found. + if ( + groupEntry.position === Infinity && + helpGroupPosition !== undefined && + helpGroupPosition !== groupEntry.position + ) { + groupEntry.position = helpGroupPosition; } - return acc; - }, - [] - ); - }, [allowedCommands]); + } else { + helpGroups.set(allowedCommand.helpGroupLabel as string, { + label: helpGroupLabel, + position: helpGroupPosition, + list: [allowedCommand], + }); + } + } + + // Sort by Group position and return an array of arrays with the list of commands per group + return sortBy(Array.from(helpGroups.values()), 'position').map((group) => { + // ensure all commands in this group have a `helpCommandPosition`. Those missing one, will + // be set to `Infinity` so that they are moved to the end. + const groupCommandList = group.list.map((command) => { + if (command.helpCommandPosition === undefined) { + return { + ...command, + helpCommandPosition: Infinity, + }; + } + + return command; + }); + + return sortBy(groupCommandList, 'helpCommandPosition'); + }); + }, [commands]); const getTableItems = useCallback( ( @@ -169,24 +214,35 @@ export const CommandList = memo(({ commands, display = 'defaul [commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel]: command, })); }, - [otherCommandsGroupLabel] + [] ); const getTableColumns = useCallback( (commandsByGroup) => { + const groupLabel = commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel; + const groupTestIdSuffix = convertToTestId(groupLabel); + return [ { - field: commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel, - name: commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel, + field: groupLabel, + name:
{groupLabel}
, render: (command: CommandDefinition) => { const commandNameWithArgs = getCommandNameWithArgs(command); return ( - + {commandNameWithArgs}, + title: ( + + {commandNameWithArgs} + + ), description: ( <> @@ -197,7 +253,6 @@ export const CommandList = memo(({ commands, display = 'defaul ), }, ]} - data-test-subj={getTestId('commandList-command')} /> {command.helpGroupLabel !== HELP_GROUPS.supporting.label && @@ -222,6 +277,9 @@ export const CommandList = memo(({ commands, display = 'defaul aria-label={`updateTextInputCommand-${command.name}`} onClick={updateInputText(`${commandNameWithArgs} `)} isDisabled={command.helpDisabled === true} + data-test-subj={getTestId( + `${groupTestIdSuffix}-${command.name}-addToInput` + )} /> @@ -232,7 +290,7 @@ export const CommandList = memo(({ commands, display = 'defaul }, ]; }, - [getTestId, otherCommandsGroupLabel, updateInputText] + [getTestId, updateInputText] ); const getFilteredCommands = useCallback( @@ -258,7 +316,11 @@ export const CommandList = memo(({ commands, display = 'defaul defaultMessage="{learnMore} about response actions and using the console." values={{ learnMore: ( - + (({ commands, display = 'defaul defaultMessage="Helpful tips:" /> } + data-test-subj={getTestId('helpfulTips')} >
    {calloutItems.map((item, index) => ( @@ -289,21 +352,24 @@ export const CommandList = memo(({ commands, display = 'defaul ); return ( - <> +
    {commandsByGroups.map((commandsByGroup, i) => ( ))} {callout} - +
    ); } return ( - <> +
    {commandsByGroups.map((commandsByGroup) => { const groupLabel = commandsByGroup[0].helpGroupLabel; @@ -345,7 +411,7 @@ export const CommandList = memo(({ commands, display = 'defaul ), }, ]} - data-test-subj={getTestId('commandList-command')} + data-test-subj={getTestId('command')} /> ); @@ -355,7 +421,7 @@ export const CommandList = memo(({ commands, display = 'defaul })} {footerMessage} - +
    ); }); CommandList.displayName = 'CommandList'; diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_header.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_header.test.tsx new file mode 100644 index 000000000000..4457347832ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_header.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 type { ConsoleProps } from '..'; +import type { AppContextTestRender } from '../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../mocks'; +import { HELP_LABEL } from './console_header'; + +describe('Console header area', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => (renderResult = testSetup.renderConsole(props)); + }); + + it('should display the help button', () => { + render(); + + expect(renderResult.getByTestId('test-header-helpButton').textContent).toEqual(HELP_LABEL); + }); + + it('should not display a title component', () => { + render(); + + expect(renderResult.getByTestId('test-header-titleComponentContainer').textContent).toEqual(''); + }); + + it('should show a title component if one was provided', () => { + render({ TitleComponent: () => <>{'header component here'} }); + + expect(renderResult.getByTestId('test-header-titleComponentContainer').textContent).toEqual( + 'header component here' + ); + }); + + it('should open the side panel when help button is clicked', () => { + render(); + renderResult.getByTestId('test-header-helpButton').click(); + + expect(renderResult.getByTestId('test-sidePanel')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/console_header.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/console_header.tsx index 4a88c525d6f2..7fbc0b53e541 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/console_header.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/console_header.tsx @@ -10,11 +10,18 @@ import styled from 'styled-components'; import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; +import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj'; +import { useTestIdGenerator } from '../../../hooks/use_test_id_generator'; import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch'; import { useWithSidePanel } from '../hooks/state_selectors/use_with_side_panel'; import type { ConsoleProps } from '..'; -const HELP_LABEL = i18n.translate('xpack.securitySolution.console.layoutHeader.helpButtonLabel', { +export const HELP_LABEL = i18n.translate( + 'xpack.securitySolution.console.layoutHeader.helpButtonTitle', + { defaultMessage: 'Help' } +); + +const HELP_TOOLTIP = i18n.translate('xpack.securitySolution.console.layoutHeader.helpButtonLabel', { defaultMessage: 'Show help', }); @@ -28,6 +35,7 @@ export type ConsoleHeaderProps = Pick; export const ConsoleHeader = memo(({ TitleComponent }) => { const dispatch = useConsoleStateDispatch(); const panelCurrentlyShowing = useWithSidePanel().show; + const getTestId = useTestIdGenerator(useDataTestSubj('header')); const isHelpOpen = panelCurrentlyShowing === 'help'; const handleHelpButtonOnClick = useCallback(() => { @@ -44,7 +52,11 @@ export const ConsoleHeader = memo(({ TitleComponent }) => { justifyContent="spaceBetween" responsive={false} > - + {TitleComponent ? : ''} {!isHelpOpen && ( @@ -53,9 +65,10 @@ export const ConsoleHeader = memo(({ TitleComponent }) => { style={{ marginLeft: 'auto' }} onClick={handleHelpButtonOnClick} iconType="help" - title={HELP_LABEL} - aria-label={HELP_LABEL} + title={HELP_TOOLTIP} + aria-label={HELP_TOOLTIP} isSelected={isHelpOpen} + data-test-subj={getTestId('helpButton')} > { render = (props = {}) => (renderResult = testSetup.renderConsole(props)); }); - it('should display all available commands when `help` command is entered', async () => { - render(); - enterCommand('help'); - - expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy(); - - await waitFor(() => { - expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength(commands.length); - }); - }); - - it('should display custom help output when Command service has `getHelp()` defined', async () => { - const HelpComponent: React.FunctionComponent = () => { - return
    {'help output'}
    ; - }; - render({ HelpComponent }); - enterCommand('help'); - - await waitFor(() => { - expect(renderResult.getByTestId('custom-help')).toBeTruthy(); - }); - }); - it('should clear the command output history when `clear` is entered', async () => { render(); enterCommand('help'); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_layout.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_layout.tsx index 82b9fdf5fb84..33e8d0acb05a 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_layout.tsx @@ -9,6 +9,8 @@ import type { ReactNode } from 'react'; import React, { memo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import styled from 'styled-components'; +import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; export interface SidePanelContentLayoutProps { children: ReactNode; @@ -24,23 +26,30 @@ const StyledEuiFlexItemNoPadding = styled(EuiFlexItem)` */ export const SidePanelContentLayout = memo( ({ headerContent, children }) => { + const getTestId = useTestIdGenerator(useDataTestSubj('sidePanel')); + return ( {headerContent && ( <> - + {headerContent} )} -
    {children}
    +
    {children}
    ); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.test.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.test.tsx new file mode 100644 index 000000000000..119dbbec0cc9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 { ConsoleProps } from '../..'; +import type { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { getConsoleTestSetup } from '../../mocks'; +import { act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +describe('When displaying the side panel', () => { + let render: (props?: Partial) => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + render = (props = {}) => { + renderResult = testSetup.renderConsole(props); + return renderResult; + }; + }); + + describe('and displaying Help content', () => { + let renderAndOpenHelp: typeof render; + + beforeEach(() => { + renderAndOpenHelp = (props) => { + render(props); + act(() => { + userEvent.click(renderResult.getByTestId('test-header-helpButton')); + }); + + expect(renderResult.getByTestId('test-sidePanel')).toBeTruthy(); + + return renderResult; + }; + }); + + it('should display the help panel content', () => { + renderAndOpenHelp(); + + expect(renderResult.getByTestId('test-sidePanel-helpContent')).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.tsx b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.tsx index a3ccf282898b..3e0296460aea 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/components/side_panel/side_panel_content_manager.tsx @@ -19,11 +19,13 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator'; import { CommandList } from '../command_list'; import { useWithCommandList } from '../../hooks/state_selectors/use_with_command_list'; import { SidePanelContentLayout } from './side_panel_content_layout'; import { useWithSidePanel } from '../../hooks/state_selectors/use_with_side_panel'; import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch'; +import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj'; const StyledEuiFlexGroup = styled(EuiFlexGroup)` padding-top: ${({ theme: { eui } }) => eui.euiPanelPaddingModifiers.paddingSmall}; @@ -33,6 +35,7 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` export const SidePanelContentManager = memo(() => { const dispatch = useConsoleStateDispatch(); const commands = useWithCommandList(); + const getTestId = useTestIdGenerator(useDataTestSubj('sidePanel')); const show = useWithSidePanel().show; const closeHelpPanel = useCallback(() => { @@ -48,7 +51,7 @@ export const SidePanelContentManager = memo(() => { <> - +

    { iconType="cross" color="text" onClick={closeHelpPanel} + data-test-subj={getTestId('headerCloseButton')} /> @@ -80,15 +84,19 @@ export const SidePanelContentManager = memo(() => { ); } return null; - }, [show, closeHelpPanel]); + }, [show, getTestId, closeHelpPanel]); const panelBody: ReactNode = useMemo(() => { if (show === 'help') { - return ; + return ( +
    + +
    + ); } return null; - }, [commands, show]); + }, [commands, getTestId, show]); if (!show) { return null; diff --git a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts index 144a5a63cd71..41643708f403 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts +++ b/x-pack/plugins/security_solution/public/management/components/console/hooks/state_selectors/use_data_test_subj.ts @@ -7,6 +7,16 @@ import { useConsoleStore } from '../../components/console_state/console_state'; -export const useDataTestSubj = (): string | undefined => { - return useConsoleStore().state.dataTestSubj; +/** + * Returns the `data-test-subj` that was defined when the `Console` was rendered. + * Can optionally set a suffix on that value if one is provided + */ +export const useDataTestSubj = (suffix: string = ''): string => { + const dataTestSubj = useConsoleStore().state.dataTestSubj; + + if (!dataTestSubj) { + return ''; + } + + return dataTestSubj + (suffix ? `-${suffix}` : ''); }; diff --git a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx index 4dc5d2050450..2af7963e1395 100644 --- a/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console/mocks.tsx @@ -11,6 +11,8 @@ import React, { memo, useEffect } from 'react'; import { EuiCode } from '@elastic/eui'; import userEvent from '@testing-library/user-event'; import { act } from '@testing-library/react'; +import { within } from '@testing-library/dom'; +import { convertToTestId } from './components/command_list'; import { Console } from './console'; import type { CommandArgumentValueSelectorProps, @@ -21,7 +23,19 @@ import type { import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; -export interface ConsoleTestSetup { +interface ConsoleSelectorsAndActionsMock { + getLeftOfCursorInputText: () => string; + getRightOfCursorInputText: () => string; + getInputText: () => string; + openHelpPanel: () => void; + closeHelpPanel: () => void; +} + +export interface ConsoleTestSetup + extends Pick< + AppContextTestRender, + 'startServices' | 'coreStart' | 'depsStart' | 'queryClient' | 'history' | 'setExperimentalFlag' + > { renderConsole(props?: Partial): ReturnType; commands: CommandDefinition[]; @@ -38,8 +52,54 @@ export interface ConsoleTestSetup { useKeyboard: boolean; }> ): void; + + selectors: ConsoleSelectorsAndActionsMock; } +/** + * A set of jest selectors and actions for interacting with the console + * @param dataTestSubj + */ +export const getConsoleSelectorsAndActionMock = ( + renderResult: ReturnType, + dataTestSubj: string = 'test' +): ConsoleTestSetup['selectors'] => { + const getLeftOfCursorInputText: ConsoleSelectorsAndActionsMock['getLeftOfCursorInputText'] = + () => { + return renderResult.getByTestId(`${dataTestSubj}-cmdInput-leftOfCursor`).textContent ?? ''; + }; + const getRightOfCursorInputText: ConsoleSelectorsAndActionsMock['getRightOfCursorInputText'] = + () => { + return renderResult.getByTestId(`${dataTestSubj}-cmdInput-rightOfCursor`).textContent ?? ''; + }; + const getInputText: ConsoleSelectorsAndActionsMock['getInputText'] = () => { + return getLeftOfCursorInputText() + getRightOfCursorInputText(); + }; + + const isHelpPanelOpen = (): boolean => { + return Boolean(renderResult.queryByTestId(`${dataTestSubj}-sidePanel-helpContent`)); + }; + + const openHelpPanel: ConsoleSelectorsAndActionsMock['openHelpPanel'] = () => { + if (!isHelpPanelOpen()) { + renderResult.getByTestId(`${dataTestSubj}-header-helpButton`).click(); + } + }; + const closeHelpPanel: ConsoleSelectorsAndActionsMock['closeHelpPanel'] = () => { + if (isHelpPanelOpen()) { + renderResult.getByTestId(`${dataTestSubj}-sidePanel-headerCloseButton`).click(); + } + }; + + return { + getInputText, + getLeftOfCursorInputText, + getRightOfCursorInputText, + openHelpPanel, + closeHelpPanel, + }; +}; + /** * Finds the console in the Render Result and enters the command provided * @param renderResult @@ -75,17 +135,23 @@ export const enterConsoleCommand = ( export const getConsoleTestSetup = (): ConsoleTestSetup => { const mockedContext = createAppRootMockRenderer(); + const { startServices, coreStart, depsStart, queryClient, history, setExperimentalFlag } = + mockedContext; let renderResult: ReturnType; const commandList = getCommandListMock(); + let testSubj: string; + const renderConsole: ConsoleTestSetup['renderConsole'] = ({ prompt = '$$>', commands = commandList, 'data-test-subj': dataTestSubj = 'test', ...others } = {}) => { + testSubj = dataTestSubj; + return (renderResult = mockedContext.render( )); @@ -95,10 +161,52 @@ export const getConsoleTestSetup = (): ConsoleTestSetup => { enterConsoleCommand(renderResult, cmd, options); }; + let selectors: ConsoleSelectorsAndActionsMock; + const initSelectorsIfNeeded = () => { + if (selectors) { + return selectors; + } + + if (!testSubj) { + throw new Error(`no 'dataTestSubj' provided to 'render()'!`); + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + selectors = getConsoleSelectorsAndActionMock(renderResult, testSubj!); + }; + return { + startServices, + coreStart, + depsStart, + queryClient, + history, + setExperimentalFlag, renderConsole, commands: commandList, enterCommand, + selectors: { + getInputText: () => { + initSelectorsIfNeeded(); + return selectors.getInputText(); + }, + getLeftOfCursorInputText: () => { + initSelectorsIfNeeded(); + return selectors.getLeftOfCursorInputText(); + }, + getRightOfCursorInputText: () => { + initSelectorsIfNeeded(); + return selectors.getRightOfCursorInputText(); + }, + openHelpPanel: () => { + initSelectorsIfNeeded(); + return selectors.openHelpPanel(); + }, + closeHelpPanel: () => { + initSelectorsIfNeeded(); + return selectors.closeHelpPanel(); + }, + }, }; }; @@ -142,11 +250,13 @@ export const getCommandListMock = (): CommandDefinition[] => { name: 'cmd1', about: 'a command with no options', RenderComponent: jest.fn(RenderComponent), + helpGroupLabel: 'group 1', }, { name: 'cmd2', about: 'runs cmd 2', RenderComponent: jest.fn(RenderComponent), + helpGroupLabel: 'group 2', args: { file: { about: 'Includes file in the run', @@ -173,6 +283,7 @@ export const getCommandListMock = (): CommandDefinition[] => { name: 'cmd3', about: 'allows argument to be used multiple times', RenderComponent: jest.fn(RenderComponent), + helpGroupPosition: 0, args: { foo: { about: 'foo stuff', @@ -186,6 +297,7 @@ export const getCommandListMock = (): CommandDefinition[] => { about: 'all options optional, but at least one is required', RenderComponent: jest.fn(RenderComponent), mustHaveArgs: true, + helpGroupPosition: 1, args: { foo: { about: 'foo stuff', @@ -226,6 +338,9 @@ export const getCommandListMock = (): CommandDefinition[] => { mustHaveArgs: true, exampleUsage: 'cmd6 --foo 123', exampleInstruction: 'Enter --foo to execute', + helpGroupLabel: 'group 1', + helpGroupPosition: 0, + helpCommandPosition: 0, args: { foo: { about: 'foo stuff', @@ -245,6 +360,7 @@ export const getCommandListMock = (): CommandDefinition[] => { name: 'cmd7', about: 'Command with argument selector', RenderComponent: jest.fn(RenderComponent), + helpGroupLabel: 'group 2', args: { foo: { about: 'foo stuff', @@ -273,3 +389,46 @@ export const ArgumentSelectorComponentMock = memo< ); }); ArgumentSelectorComponentMock.displayName = 'ArgumentSelectorComponentMock'; + +export interface HelpSidePanelSelectorsAndActions { + getHelpGroupLabels: () => string[]; + getHelpCommandNames: (forGroup?: string) => string[]; +} + +export const getHelpSidePanelSelectorsAndActionsMock = ( + renderResult: ReturnType, + dataTestSubj: string = 'test' +): HelpSidePanelSelectorsAndActions => { + const getHelpGroupLabels: HelpSidePanelSelectorsAndActions['getHelpGroupLabels'] = () => { + // FYI: we're collapsing the labels here because EUI includes mobile elements + // in the DOM that have the same test ids + return Array.from( + new Set( + renderResult + .getAllByTestId(`${dataTestSubj}-commandList-group`) + .map((element) => element.textContent ?? '') + ) + ); + }; + + const getHelpCommandNames: HelpSidePanelSelectorsAndActions['getHelpCommandNames'] = ( + forGroup + ) => { + let searchContainer = renderResult.container; + + if (forGroup) { + searchContainer = renderResult.getByTestId( + `${dataTestSubj}-commandList-${convertToTestId(forGroup)}` + ); + } + + return within(searchContainer) + .getAllByTestId(`${dataTestSubj}-commandList-commandName`) + .map((commandEle) => commandEle.textContent ?? ''); + }; + + return { + getHelpGroupLabels, + getHelpCommandNames, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts index 4c603888bdcc..02ce95fbc25b 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/console_commands_definition.ts @@ -118,7 +118,7 @@ const capabilitiesAndPrivilegesValidator = (command: Command): true | string => return true; }; -const HELP_GROUPS = Object.freeze({ +export const HELP_GROUPS = Object.freeze({ responseActions: { position: 0, label: i18n.translate('xpack.securitySolution.endpointConsoleCommands.groups.responseActions', { diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx new file mode 100644 index 000000000000..f5e96f0e8a70 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/lib/integration_tests/console_commands_definition.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 { ConsoleTestSetup, HelpSidePanelSelectorsAndActions } from '../../../console/mocks'; +import { + getConsoleTestSetup, + getHelpSidePanelSelectorsAndActionsMock, +} from '../../../console/mocks'; +import { getEndpointConsoleCommands } from '../..'; +import { EndpointMetadataGenerator } from '../../../../../../common/endpoint/data_generators/endpoint_metadata_generator'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks'; +import { sortBy } from 'lodash'; +import { HELP_GROUPS } from '../console_commands_definition'; + +describe('When displaying Endpoint Response Actions', () => { + let render: ConsoleTestSetup['renderConsole']; + let renderResult: ReturnType; + let consoleSelectors: ConsoleTestSetup['selectors']; + let helpPanelSelectors: HelpSidePanelSelectorsAndActions; + + beforeEach(() => { + const testSetup = getConsoleTestSetup(); + + testSetup.setExperimentalFlag({ + responseActionGetFileEnabled: true, + responseActionExecuteEnabled: true, + }); + + const endpointMetadata = new EndpointMetadataGenerator().generate(); + const commands = getEndpointConsoleCommands({ + endpointAgentId: '123', + endpointCapabilities: endpointMetadata.Endpoint.capabilities ?? [], + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + + consoleSelectors = testSetup.selectors; + render = (props = { commands }) => { + renderResult = testSetup.renderConsole(props); + helpPanelSelectors = getHelpSidePanelSelectorsAndActionsMock(renderResult); + + return renderResult; + }; + }); + + it('should display expected help groups', () => { + render(); + consoleSelectors.openHelpPanel(); + + expect(helpPanelSelectors.getHelpGroupLabels()).toEqual([ + ...sortBy(Object.values(HELP_GROUPS), 'position').map((group) => group.label), + 'Supporting commands & parameters', + ]); + }); + + it('should display response action commands in the help panel in expected order', () => { + render(); + consoleSelectors.openHelpPanel(); + const commands = helpPanelSelectors.getHelpCommandNames(HELP_GROUPS.responseActions.label); + + expect(commands).toEqual([ + 'isolate', + 'release', + 'status', + 'processes', + 'kill-process --pid', + 'suspend-process --pid', + 'get-file --path', + 'execute --command', + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/hooks/use_test_id_generator.ts b/x-pack/plugins/security_solution/public/management/hooks/use_test_id_generator.ts index 35ba460b7e87..e8048d110224 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/use_test_id_generator.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/use_test_id_generator.ts @@ -16,6 +16,7 @@ import { useCallback } from 'react'; * @example * // `props['data-test-subj'] = 'abc'; * const getTestId = useTestIdGenerator(props['data-test-subj']); + * getTestId(); // abc * getTestId('body'); // abc-body * getTestId('some-other-ui-section'); // abc-some-other-ui-section * @@ -24,11 +25,11 @@ import { useCallback } from 'react'; * const getTestId = useTestIdGenerator(props['data-test-subj']); * getTestId('body'); // undefined */ -export const useTestIdGenerator = (prefix?: string): ((suffix: string) => string | undefined) => { +export const useTestIdGenerator = (prefix?: string): ((suffix?: string) => string | undefined) => { return useCallback( - (suffix: string): string | undefined => { + (suffix: string = ''): string | undefined => { if (prefix) { - return `${prefix}-${suffix}`; + return `${prefix}${suffix ? `-${suffix}` : ''}`; } }, [prefix]