diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts index ecf049ab379ef..70c74e473d1d0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts @@ -120,10 +120,10 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze< release: 'canUnIsolateHost', execute: 'canWriteFileOperations', 'get-file': 'canWriteFileOperations', + upload: 'canWriteFileOperations', processes: 'canGetRunningProcesses', 'kill-process': 'canKillProcess', 'suspend-process': 'canSuspendProcess', - upload: 'canWriteExecuteOperations', }); // 4 hrs in seconds 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 2af7963e13959..ac729f898c6be 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 @@ -29,17 +29,9 @@ interface ConsoleSelectorsAndActionsMock { getInputText: () => string; openHelpPanel: () => void; closeHelpPanel: () => void; -} - -export interface ConsoleTestSetup - extends Pick< - AppContextTestRender, - 'startServices' | 'coreStart' | 'depsStart' | 'queryClient' | 'history' | 'setExperimentalFlag' - > { - renderConsole(props?: Partial): ReturnType; - - commands: CommandDefinition[]; - + /** Clicks on the submit button on the far right of the console's input area */ + submitCommand: () => void; + /** Enters a command into the console's input area */ enterCommand( cmd: string, options?: Partial<{ @@ -52,6 +44,18 @@ export interface ConsoleTestSetup useKeyboard: boolean; }> ): void; +} + +export interface ConsoleTestSetup + extends Pick< + AppContextTestRender, + 'startServices' | 'coreStart' | 'depsStart' | 'queryClient' | 'history' | 'setExperimentalFlag' + > { + renderConsole(props?: Partial): ReturnType; + + commands: CommandDefinition[]; + + enterCommand: ConsoleSelectorsAndActionsMock['enterCommand']; selectors: ConsoleSelectorsAndActionsMock; } @@ -90,6 +94,12 @@ export const getConsoleSelectorsAndActionMock = ( renderResult.getByTestId(`${dataTestSubj}-sidePanel-headerCloseButton`).click(); } }; + const submitCommand: ConsoleSelectorsAndActionsMock['submitCommand'] = () => { + renderResult.getByTestId(`${dataTestSubj}-inputTextSubmitButton`).click(); + }; + const enterCommand: ConsoleSelectorsAndActionsMock['enterCommand'] = (cmd, options = {}) => { + enterConsoleCommand(renderResult, cmd, options); + }; return { getInputText, @@ -97,6 +107,8 @@ export const getConsoleSelectorsAndActionMock = ( getRightOfCursorInputText, openHelpPanel, closeHelpPanel, + submitCommand, + enterCommand, }; }; @@ -206,6 +218,17 @@ export const getConsoleTestSetup = (): ConsoleTestSetup => { initSelectorsIfNeeded(); return selectors.closeHelpPanel(); }, + submitCommand: () => { + initSelectorsIfNeeded(); + return selectors.submitCommand(); + }, + enterCommand: ( + cmd: string, + options?: Partial<{ inputOnly: boolean; useKeyboard: boolean }> + ) => { + initSelectorsIfNeeded(); + return selectors.enterCommand(cmd, options); + }, }, }; }; diff --git a/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx index 4dda91f2ab232..2373caf38ba39 100644 --- a/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx +++ b/x-pack/plugins/security_solution/public/management/components/console_argument_selectors/file_selector.tsx @@ -122,6 +122,7 @@ export const ArgumentFileSelector = memo< onChange={handleFileSelection} fullWidth display="large" + data-test-subj="console-arg-file-picker" /> )} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/upload_action.test.tsx b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/upload_action.test.tsx new file mode 100644 index 0000000000000..07b081188f803 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/endpoint_responder/command_render_components/integration_tests/upload_action.test.tsx @@ -0,0 +1,203 @@ +/* + * 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 EndpointCapabilities, + ENDPOINT_CAPABILITIES, +} from '../../../../../../common/endpoint/service/response_actions/constants'; +import { + type AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { responseActionsHttpMocks } from '../../../../mocks/response_actions_http_mocks'; +import { + ConsoleManagerTestComponent, + getConsoleManagerMockRenderResultQueriesAndActions, +} from '../../../console/components/console_manager/mocks'; +import type { EndpointPrivileges } from '../../../../../../common/endpoint/types'; +import { getEndpointAuthzInitialStateMock } from '../../../../../../common/endpoint/service/authz/mocks'; +import { getEndpointConsoleCommands } from '../..'; +import React from 'react'; +import { getConsoleSelectorsAndActionMock } from '../../../console/mocks'; +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { executionTranslations } from '../../../console/components/console_state/state_update_handlers/translations'; +import { UPLOAD_ROUTE } from '../../../../../../common/endpoint/constants'; +import type { HttpFetchOptionsWithPath } from '@kbn/core-http-browser'; +import { + INSUFFICIENT_PRIVILEGES_FOR_COMMAND, + UPGRADE_ENDPOINT_FOR_RESPONDER, +} from '../../../../../common/translations'; + +describe('When using `upload` response action', () => { + let render: ( + capabilities?: EndpointCapabilities[] + ) => Promise>; + let renderResult: ReturnType; + let apiMocks: ReturnType; + let consoleManagerMockAccess: ReturnType< + typeof getConsoleManagerMockRenderResultQueriesAndActions + >; + let endpointPrivileges: EndpointPrivileges; + let endpointCapabilities: typeof ENDPOINT_CAPABILITIES; + let file: File; + let console: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + mockedContext.setExperimentalFlag({ responseActionUploadEnabled: true }); + apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http); + endpointPrivileges = { ...getEndpointAuthzInitialStateMock(), loading: false }; + endpointCapabilities = [...ENDPOINT_CAPABILITIES]; + + const fileContent = new Blob(['test']); + file = new File([fileContent], 'test.json', { type: 'application/JSON' }); + + render = async () => { + renderResult = mockedContext.render( + { + return { + consoleProps: { + 'data-test-subj': 'test', + commands: getEndpointConsoleCommands({ + endpointAgentId: 'a.b.c', + endpointCapabilities, + endpointPrivileges, + }), + }, + }; + }} + /> + ); + + console = getConsoleSelectorsAndActionMock(renderResult); + consoleManagerMockAccess = getConsoleManagerMockRenderResultQueriesAndActions(renderResult); + + await consoleManagerMockAccess.clickOnRegisterNewConsole(); + await consoleManagerMockAccess.openRunningConsole(); + + return renderResult; + }; + }); + + afterEach(() => { + // @ts-expect-error assignment of `undefined` to avoid leak from one test to the other + console = undefined; + // @ts-expect-error assignment of `undefined` to avoid leak from one test to the other + consoleManagerMockAccess = undefined; + }); + + it('should require `--file` argument', async () => { + await render(); + console.enterCommand('upload'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument-message')).toHaveTextContent( + executionTranslations.missingArguments('--file') + ); + }); + }); + + it('should error if `--file` argument is not set (no file selected)', async () => { + await render(); + console.enterCommand('upload --file'); + + await waitFor(() => { + expect(renderResult.getByTestId('test-badArgument-message')).toHaveTextContent( + executionTranslations.mustHaveValue('file') + ); + }); + }); + + it('should call upload api with expected payload', async () => { + const { getByTestId } = await render(); + console.enterCommand('upload --file', { inputOnly: true }); + + await waitFor(() => { + userEvent.upload(getByTestId('console-arg-file-picker'), file); + }); + + console.submitCommand(); + + await waitFor(() => { + expect(apiMocks.responseProvider.upload).toHaveBeenCalledWith({ + body: expect.any(FormData), + headers: { + 'Content-Type': undefined, + }, + path: UPLOAD_ROUTE, + }); + }); + + const apiBody = ( + apiMocks.responseProvider.upload.mock.lastCall as unknown as [HttpFetchOptionsWithPath] + )?.[0].body as FormData; + + expect(apiBody.get('file')).toEqual(file); + expect(apiBody.get('endpoint_ids')).toEqual('["a.b.c"]'); + expect(apiBody.get('parameters')).toEqual('{}'); + }); + + it('should allow `--overwrite` argument', async () => { + const { getByTestId } = await render(); + console.enterCommand('upload --overwrite --file', { inputOnly: true }); + + await waitFor(() => { + userEvent.upload(getByTestId('console-arg-file-picker'), file); + }); + + console.submitCommand(); + + await waitFor(() => { + expect(apiMocks.responseProvider.upload).toHaveBeenCalled(); + }); + + const apiBody = ( + apiMocks.responseProvider.upload.mock.lastCall as unknown as [HttpFetchOptionsWithPath] + )?.[0].body as FormData; + + expect(apiBody.get('parameters')).toEqual('{"overwrite":true}'); + }); + + it('should show an error if user has no authz to file operations', async () => { + endpointPrivileges.canWriteFileOperations = false; + const { getByTestId } = await render(); + console.enterCommand('upload --overwrite --file', { inputOnly: true }); + + await waitFor(() => { + userEvent.upload(getByTestId('console-arg-file-picker'), file); + }); + + console.submitCommand(); + + await waitFor(() => { + expect(getByTestId('test-validationError-message').textContent).toEqual( + INSUFFICIENT_PRIVILEGES_FOR_COMMAND + ); + }); + }); + + it('should show an error if the endpoint does not support `upload_file` capability', async () => { + endpointCapabilities = [] as unknown as typeof ENDPOINT_CAPABILITIES; + const { getByTestId } = await render(); + console.enterCommand('upload --overwrite --file', { inputOnly: true }); + + await waitFor(() => { + userEvent.upload(getByTestId('console-arg-file-picker'), file); + }); + + console.submitCommand(); + + await waitFor(() => { + expect(getByTestId('test-validationError-message').textContent).toEqual( + UPGRADE_ENDPOINT_FOR_RESPONDER + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts index 40f1f7ce6c3a8..05a02603de615 100644 --- a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts @@ -19,6 +19,7 @@ import { GET_FILE_ROUTE, ACTION_AGENT_FILE_INFO_ROUTE, EXECUTE_ROUTE, + UPLOAD_ROUTE, } from '../../../common/endpoint/constants'; import type { ResponseProvidersInterface } from '../../common/mock/endpoint/http_handler_mock_factory'; import { httpHandlerMockFactory } from '../../common/mock/endpoint/http_handler_mock_factory'; @@ -34,6 +35,8 @@ import type { ActionFileInfoApiResponse, ResponseActionExecuteOutputContent, ResponseActionsExecuteParameters, + ResponseActionUploadOutputContent, + ResponseActionUploadParameters, } from '../../../common/endpoint/types'; export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{ @@ -58,6 +61,11 @@ export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{ fileInfo: () => ActionFileInfoApiResponse; execute: () => ActionDetailsApiResponse; + + upload: () => ActionDetailsApiResponse< + ResponseActionUploadOutputContent, + ResponseActionUploadParameters + >; }>; export const responseActionsHttpMocks = httpHandlerMockFactory([ @@ -218,6 +226,25 @@ export const responseActionsHttpMocks = httpHandlerMockFactory => { + const generator = new EndpointActionGenerator('seed'); + const response = generator.generateActionDetails< + ResponseActionUploadOutputContent, + ResponseActionUploadParameters + >({ + command: 'upload', + }); + return { data: response }; }, },