mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Security Solution][Endpoint] Response actions console isolate command (#131876)
* Delete the Security Solution Top Header consoles component * Adds a menu item to the Alert details flyout, for Endpoint alerts only, that will launch the Responder console * Adds a menu item to the Endpoint List and Endpoint details flyout for launching the Responder console * Adds console's isolate command (send a request to isolate endpoint) * Adds console's status command (endpoint status) * Adds additional render functions to the Endpoint App Test renderer (renderHook(), renderReactQueryHook())
This commit is contained in:
parent
0103f1a22e
commit
577752ae7c
68 changed files with 2591 additions and 828 deletions
|
@ -11,8 +11,10 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
|||
import { ENDPOINT_ACTION_RESPONSES_DS, ENDPOINT_ACTIONS_INDEX } from '../constants';
|
||||
import { BaseDataGenerator } from './base_data_generator';
|
||||
import {
|
||||
ActionDetails,
|
||||
ActivityLogItemTypes,
|
||||
EndpointActivityLogActionResponse,
|
||||
EndpointPendingActions,
|
||||
ISOLATION_ACTIONS,
|
||||
LogsEndpointAction,
|
||||
LogsEndpointActionResponse,
|
||||
|
@ -103,6 +105,82 @@ export class EndpointActionGenerator extends BaseDataGenerator {
|
|||
});
|
||||
}
|
||||
|
||||
generateActionDetails(overrides: DeepPartial<ActionDetails> = {}): ActionDetails {
|
||||
const details: ActionDetails = {
|
||||
agents: ['agent-a'],
|
||||
command: 'isolate',
|
||||
completedAt: '2022-04-30T16:08:47.449Z',
|
||||
id: '123',
|
||||
isCompleted: true,
|
||||
isExpired: false,
|
||||
wasSuccessful: true,
|
||||
errors: undefined,
|
||||
logEntries: [
|
||||
{
|
||||
item: {
|
||||
data: {
|
||||
'@timestamp': '2022-04-27T16:08:47.449Z',
|
||||
action_id: '123',
|
||||
agents: ['agent-a'],
|
||||
data: {
|
||||
command: 'isolate',
|
||||
comment: '5wb6pu6kh2xix5i',
|
||||
},
|
||||
expiration: '2022-04-29T16:08:47.449Z',
|
||||
input_type: 'endpoint',
|
||||
type: 'INPUT_ACTION',
|
||||
user_id: 'elastic',
|
||||
},
|
||||
id: '44d8b915-c69c-4c48-8c86-b57d0bd631d0',
|
||||
},
|
||||
type: 'fleetAction',
|
||||
},
|
||||
{
|
||||
item: {
|
||||
data: {
|
||||
'@timestamp': '2022-04-30T16:08:47.449Z',
|
||||
action_data: {
|
||||
command: 'unisolate',
|
||||
comment: '',
|
||||
},
|
||||
action_id: '123',
|
||||
agent_id: 'agent-a',
|
||||
completed_at: '2022-04-30T16:08:47.449Z',
|
||||
error: '',
|
||||
started_at: '2022-04-30T16:08:47.449Z',
|
||||
},
|
||||
id: '54-65-65-98',
|
||||
},
|
||||
type: 'fleetResponse',
|
||||
},
|
||||
{
|
||||
item: {
|
||||
data: {
|
||||
'@timestamp': '2022-04-30T16:08:47.449Z',
|
||||
EndpointActions: {
|
||||
action_id: '123',
|
||||
completed_at: '2022-04-30T16:08:47.449Z',
|
||||
data: {
|
||||
command: 'unisolate',
|
||||
comment: '',
|
||||
},
|
||||
started_at: '2022-04-30T16:08:47.449Z',
|
||||
},
|
||||
agent: {
|
||||
id: 'agent-a',
|
||||
},
|
||||
},
|
||||
id: '32-65-98',
|
||||
},
|
||||
type: 'response',
|
||||
},
|
||||
],
|
||||
startedAt: '2022-04-27T16:08:47.449Z',
|
||||
};
|
||||
|
||||
return merge(details, overrides);
|
||||
}
|
||||
|
||||
generateActivityLogActionResponse(
|
||||
overrides: DeepPartial<EndpointActivityLogActionResponse>
|
||||
): EndpointActivityLogActionResponse {
|
||||
|
@ -118,6 +196,21 @@ export class EndpointActionGenerator extends BaseDataGenerator {
|
|||
);
|
||||
}
|
||||
|
||||
generateAgentPendingActionsSummary(
|
||||
overrides: Partial<EndpointPendingActions> = {}
|
||||
): EndpointPendingActions {
|
||||
return merge(
|
||||
{
|
||||
agent_id: this.seededUUIDv4(),
|
||||
pending_actions: {
|
||||
isolate: 2,
|
||||
unisolate: 0,
|
||||
},
|
||||
},
|
||||
overrides
|
||||
);
|
||||
}
|
||||
|
||||
randomFloat(): number {
|
||||
return this.random();
|
||||
}
|
||||
|
|
|
@ -200,8 +200,17 @@ export interface ActionDetails {
|
|||
* performed on the endpoint
|
||||
*/
|
||||
command: string;
|
||||
/**
|
||||
* Will be set to true only if action is not yet completed and elapsed time has exceeded
|
||||
* the request's expiration date
|
||||
*/
|
||||
isExpired: boolean;
|
||||
/** Action has been completed */
|
||||
isCompleted: boolean;
|
||||
/** If the action was successful */
|
||||
wasSuccessful: boolean;
|
||||
/** Any errors encountered if `wasSuccessful` is `false` */
|
||||
errors: undefined | string[];
|
||||
/** The date when the initial action request was submitted */
|
||||
startedAt: string;
|
||||
/** The date when the action was completed (a response by the endpoint (not fleet) was received) */
|
||||
|
|
|
@ -27,7 +27,6 @@ import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
|
|||
import { timelineSelectors } from '../../../timelines/store/timeline';
|
||||
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
import { getScopeFromPath, showSourcererByPath } from '../../../common/containers/sourcerer';
|
||||
import { ConsolesPopoverHeaderSectionItem } from '../../../common/components/consoles_popover_header_section_item';
|
||||
|
||||
const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.buttonAddData', {
|
||||
defaultMessage: 'Add integrations',
|
||||
|
@ -73,9 +72,6 @@ export const GlobalHeader = React.memo(
|
|||
return (
|
||||
<InPortal node={portalNode}>
|
||||
<EuiHeaderSection side="right">
|
||||
{/* The consoles Popover may or may not be shown, depending on the user's authz */}
|
||||
<ConsolesPopoverHeaderSectionItem />
|
||||
|
||||
{isDetectionsPath(pathname) && (
|
||||
<EuiHeaderSectionItem>
|
||||
<MlPopover />
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { mockTimelineModel } from '../../../common/mock';
|
||||
import { mockTimelineModel } from '../../../common/mock/timeline_results';
|
||||
import { useFormatUrl } from '../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../app/types';
|
||||
import { useInsertTimeline } from '.';
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
/*
|
||||
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../mock/endpoint';
|
||||
import { ConsolesPopoverHeaderSectionItem } from './consoles_popover_header_section_item';
|
||||
import { useUserPrivileges as _useUserPrivileges } from '../user_privileges';
|
||||
import { getEndpointPrivilegesInitialStateMock } from '../user_privileges/endpoint/mocks';
|
||||
|
||||
jest.mock('../user_privileges');
|
||||
const userUserPrivilegesMock = _useUserPrivileges as jest.Mock;
|
||||
|
||||
describe('When rendering the `ConsolesPopoverHeaderSectionItem`', () => {
|
||||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<typeof render>;
|
||||
let setExperimentalFlag: AppContextTestRender['setExperimentalFlag'];
|
||||
|
||||
beforeEach(() => {
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
|
||||
setExperimentalFlag = mockedContext.setExperimentalFlag;
|
||||
setExperimentalFlag({ responseActionsConsoleEnabled: true });
|
||||
render = () => {
|
||||
return (renderResult = mockedContext.render(<ConsolesPopoverHeaderSectionItem />));
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
userUserPrivilegesMock.mockReturnValue({
|
||||
...userUserPrivilegesMock(),
|
||||
endpointPrivileges: getEndpointPrivilegesInitialStateMock(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should show menu item if feature flag is true and user has authz to endpoint management', () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.getByTestId('endpointConsoles')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide the menu item if feature flag is false', () => {
|
||||
setExperimentalFlag({ responseActionsConsoleEnabled: false });
|
||||
render();
|
||||
|
||||
expect(renderResult.queryByTestId('endpointConsoles')).toBeNull();
|
||||
});
|
||||
|
||||
it('should hide menu item if user does not have authz to endpoint management', () => {
|
||||
userUserPrivilegesMock.mockReturnValue({
|
||||
...userUserPrivilegesMock(),
|
||||
endpointPrivileges: getEndpointPrivilegesInitialStateMock({
|
||||
canAccessEndpointManagement: false,
|
||||
}),
|
||||
});
|
||||
render();
|
||||
|
||||
expect(renderResult.queryByTestId('endpointConsoles')).toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,77 +0,0 @@
|
|||
/*
|
||||
* 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, useState, useCallback, useMemo } from 'react';
|
||||
import { EuiHeaderSectionItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useUserPrivileges } from '../user_privileges';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
|
||||
|
||||
const LABELS = Object.freeze({
|
||||
buttonLabel: i18n.translate('xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel', {
|
||||
defaultMessage: 'Endpoint consoles',
|
||||
}),
|
||||
});
|
||||
|
||||
const ConsolesPopover = memo(() => {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
|
||||
|
||||
const handlePopoverToggle = useCallback(() => {
|
||||
setIsPopoverOpen((prevState) => !prevState);
|
||||
}, []);
|
||||
|
||||
const handlePopoverClose = useCallback(() => {
|
||||
setIsPopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
const buttonTextProps = useMemo(() => {
|
||||
return { style: { fontSize: '1rem' } };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EuiHeaderSectionItem>
|
||||
<EuiPopover
|
||||
anchorPosition="downRight"
|
||||
id="consoles-popover"
|
||||
button={
|
||||
<EuiHeaderSectionItemButton
|
||||
aria-expanded={isPopoverOpen}
|
||||
aria-haspopup="true"
|
||||
aria-label={LABELS.buttonLabel}
|
||||
color="primary"
|
||||
data-test-subj="endpointConsoles"
|
||||
iconType="console"
|
||||
iconSide="left"
|
||||
onClick={handlePopoverToggle}
|
||||
textProps={buttonTextProps}
|
||||
>
|
||||
{LABELS.buttonLabel}
|
||||
</EuiHeaderSectionItemButton>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={handlePopoverClose}
|
||||
repositionOnScroll
|
||||
>
|
||||
{
|
||||
'TODO: Currently open consoles and the ability to start a new console will be shown here soon'
|
||||
}
|
||||
</EuiPopover>
|
||||
</EuiHeaderSectionItem>
|
||||
);
|
||||
});
|
||||
ConsolesPopover.displayName = 'ConsolesPopover';
|
||||
|
||||
export const ConsolesPopoverHeaderSectionItem = memo((props) => {
|
||||
const canAccessEndpointManagement =
|
||||
useUserPrivileges().endpointPrivileges.canAccessEndpointManagement;
|
||||
const isExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled(
|
||||
'responseActionsConsoleEnabled'
|
||||
);
|
||||
|
||||
return canAccessEndpointManagement && isExperimentalFeatureEnabled ? <ConsolesPopover /> : null;
|
||||
});
|
||||
ConsolesPopoverHeaderSectionItem.displayName = 'ConsolesPopoverHeaderSectionItem';
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export const mockAlertDetailsData = [
|
||||
export const generateAlertDetailsDataMock = () => [
|
||||
{ category: 'process', field: 'process.name', values: ['-'], originalValue: '-' },
|
||||
{ category: 'process', field: 'process.pid', values: [0], originalValue: 0 },
|
||||
{ category: 'process', field: 'process.executable', values: ['-'], originalValue: '-' },
|
||||
|
@ -654,3 +654,5 @@ export const mockAlertDetailsData = [
|
|||
originalValue: ['dummy.exe'],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAlertDetailsData = generateAlertDetailsDataMock();
|
||||
|
|
|
@ -334,5 +334,55 @@ exports[`Event Details Overview Cards renders rows and spacers correctly 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
.c0 {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 20px;
|
||||
height: 50vh;
|
||||
width: 48vw;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.c0.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.c0.is-confirming .modal-content {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.c0 .console-holder {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.c0 .terminate-confirm-panel {
|
||||
max-width: 85%;
|
||||
-webkit-box-flex: 0;
|
||||
-webkit-flex-grow: 0;
|
||||
-ms-flex-positive: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 euiModal euiModal--maxWidth-default is-hidden"
|
||||
data-test-subj="consolePopupWrapper"
|
||||
>
|
||||
<div
|
||||
class="euiModal__flex modal-content"
|
||||
>
|
||||
<div
|
||||
class="euiModalBody"
|
||||
data-test-subj="consolePopupBody"
|
||||
>
|
||||
<div
|
||||
class="euiModalBody__overflow"
|
||||
>
|
||||
<div
|
||||
class="console-holder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
|
|
@ -41,7 +41,7 @@ describe('Last Event Time Stat', () => {
|
|||
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.html()).toBe(
|
||||
expect(wrapper.find(LastEventTime).html()).toBe(
|
||||
'<span class="euiLoadingSpinner euiLoadingSpinner--medium"></span>'
|
||||
);
|
||||
});
|
||||
|
@ -58,7 +58,9 @@ describe('Last Event Time Stat', () => {
|
|||
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.html()).toBe('Last event: <span class="euiToolTipAnchor">20 hours ago</span>');
|
||||
expect(wrapper.find(LastEventTime).html()).toBe(
|
||||
'Last event: <span class="euiToolTipAnchor">20 hours ago</span>'
|
||||
);
|
||||
});
|
||||
test('Bad date time string', async () => {
|
||||
(useTimelineLastEventTime as jest.Mock).mockReturnValue([
|
||||
|
@ -74,7 +76,7 @@ describe('Last Event Time Stat', () => {
|
|||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.html()).toBe('something-invalid');
|
||||
expect(wrapper.find(LastEventTime).html()).toBe('something-invalid');
|
||||
});
|
||||
test('Null time string', async () => {
|
||||
(useTimelineLastEventTime as jest.Mock).mockReturnValue([
|
||||
|
@ -89,6 +91,6 @@ describe('Last Event Time Stat', () => {
|
|||
<LastEventTime docValueFields={[]} indexKey={LastEventIndexKey.hosts} indexNames={[]} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.html()).toContain(getEmptyValue());
|
||||
expect(wrapper.find(LastEventTime).html()).toContain(getEmptyValue());
|
||||
});
|
||||
});
|
||||
|
|
|
@ -97,5 +97,55 @@ exports[`SessionsView renders correctly against snapshot 1`] = `
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
.c0 {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 20px;
|
||||
height: 50vh;
|
||||
width: 48vw;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.c0.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.c0.is-confirming .modal-content {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.c0 .console-holder {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.c0 .terminate-confirm-panel {
|
||||
max-width: 85%;
|
||||
-webkit-box-flex: 0;
|
||||
-webkit-flex-grow: 0;
|
||||
-ms-flex-positive: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
<div
|
||||
class="c0 euiModal euiModal--maxWidth-default is-hidden"
|
||||
data-test-subj="consolePopupWrapper"
|
||||
>
|
||||
<div
|
||||
class="euiModal__flex modal-content"
|
||||
>
|
||||
<div
|
||||
class="euiModalBody"
|
||||
data-test-subj="consolePopupBody"
|
||||
>
|
||||
<div
|
||||
class="euiModalBody__overflow"
|
||||
>
|
||||
<div
|
||||
class="console-holder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
|
|
@ -13,7 +13,15 @@ import { AppDeepLink } from '@kbn/core/public';
|
|||
import { QueryClient, QueryClientProvider, setLogger } from 'react-query';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { PLUGIN_ID } from '@kbn/fleet-plugin/common';
|
||||
import { StartPlugins, StartServices } from '../../../types';
|
||||
import {
|
||||
renderHook as reactRenderHoook,
|
||||
RenderHookOptions,
|
||||
RenderHookResult,
|
||||
} from '@testing-library/react-hooks';
|
||||
import { ReactHooksRenderer, WrapperComponent } from '@testing-library/react-hooks/src/types/react';
|
||||
import type { UseBaseQueryResult } from 'react-query/types/react/types';
|
||||
import { ConsoleManager } from '../../../management/components/console';
|
||||
import type { StartPlugins, StartServices } from '../../../types';
|
||||
import { depsStartMock } from './dependencies_start_mock';
|
||||
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils';
|
||||
import { kibanaObservable } from '../test_providers';
|
||||
|
@ -30,6 +38,44 @@ import { fleetGetPackageListHttpMock } from '../../../management/pages/mocks';
|
|||
|
||||
export type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
|
||||
|
||||
/**
|
||||
* Have the renderer wait for one of the ReactQuery state flag properties. Default is `isSuccess`.
|
||||
* To disable this `await`, the value `false` can be used.
|
||||
*/
|
||||
export type WaitForReactHookState =
|
||||
| keyof Pick<
|
||||
UseBaseQueryResult,
|
||||
| 'isSuccess'
|
||||
| 'isLoading'
|
||||
| 'isError'
|
||||
| 'isIdle'
|
||||
| 'isLoadingError'
|
||||
| 'isStale'
|
||||
| 'isFetched'
|
||||
| 'isFetching'
|
||||
| 'isRefetching'
|
||||
>
|
||||
| false;
|
||||
|
||||
type HookRendererFunction<TProps, TResult> = (props: TProps) => TResult;
|
||||
|
||||
/**
|
||||
* A utility renderer for hooks that return React Query results
|
||||
*/
|
||||
export type ReactQueryHookRenderer<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
TProps = any,
|
||||
TResult extends UseBaseQueryResult = UseBaseQueryResult
|
||||
> = (
|
||||
hookFn: HookRendererFunction<TProps, TResult>,
|
||||
/**
|
||||
* If defined (default is `isSuccess`), the renderer will wait for the given react
|
||||
* query response state value to be true
|
||||
*/
|
||||
waitForHook?: WaitForReactHookState,
|
||||
options?: RenderHookOptions<TProps>
|
||||
) => Promise<TResult>;
|
||||
|
||||
// hide react-query output in console
|
||||
setLogger({
|
||||
error: () => {},
|
||||
|
@ -61,6 +107,16 @@ export interface AppContextTestRender {
|
|||
*/
|
||||
render: UiRender;
|
||||
|
||||
/**
|
||||
* Renders a hook within a mocked security solution app context
|
||||
*/
|
||||
renderHook: ReactHooksRenderer['renderHook'];
|
||||
|
||||
/**
|
||||
* A helper utility for rendering specifically hooks that wrap ReactQuery
|
||||
*/
|
||||
renderReactQueryHook: ReactQueryHookRenderer;
|
||||
|
||||
/**
|
||||
* Set technical preview features on/off. Calling this method updates the Store with the new values
|
||||
* for the given feature flags
|
||||
|
@ -136,7 +192,9 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
|
||||
<KibanaContextProvider services={startServices}>
|
||||
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConsoleManager>{children}</ConsoleManager>
|
||||
</QueryClientProvider>
|
||||
</AppRootProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
|
@ -148,6 +206,35 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
});
|
||||
};
|
||||
|
||||
const renderHook: ReactHooksRenderer['renderHook'] = <TProps, TResult>(
|
||||
hookFn: HookRendererFunction<TProps, TResult>,
|
||||
options: RenderHookOptions<TProps> = {}
|
||||
): RenderHookResult<TProps, TResult> => {
|
||||
return reactRenderHoook<TProps, TResult>(hookFn, {
|
||||
wrapper: AppWrapper as WrapperComponent<TProps>,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
const renderReactQueryHook: ReactQueryHookRenderer = async <
|
||||
TProps,
|
||||
TResult extends UseBaseQueryResult = UseBaseQueryResult
|
||||
>(
|
||||
hookFn: HookRendererFunction<TProps, TResult>,
|
||||
waitForHook: WaitForReactHookState = 'isSuccess',
|
||||
options: RenderHookOptions<TProps> = {}
|
||||
) => {
|
||||
const { result: hookResult, waitFor } = renderHook<TProps, TResult>(hookFn, options);
|
||||
|
||||
if (waitForHook) {
|
||||
await waitFor(() => {
|
||||
return hookResult.current[waitForHook];
|
||||
});
|
||||
}
|
||||
|
||||
return hookResult.current;
|
||||
};
|
||||
|
||||
const setExperimentalFlag: AppContextTestRender['setExperimentalFlag'] = (flags) => {
|
||||
store.dispatch({
|
||||
type: UpdateExperimentalFeaturesTestActionType,
|
||||
|
@ -181,6 +268,8 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
|
|||
middlewareSpy,
|
||||
AppWrapper,
|
||||
render,
|
||||
renderHook,
|
||||
renderReactQueryHook,
|
||||
setExperimentalFlag,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -92,6 +92,7 @@ interface RouteMock<R extends ResponseProvidersInterface = ResponseProvidersInte
|
|||
*/
|
||||
id: keyof R;
|
||||
method: HttpMethods;
|
||||
/** The API path to match on. This value could can have tokens in the format of `{token_name}` */
|
||||
path: string;
|
||||
/**
|
||||
* The handler for providing a response to for this API call.
|
||||
|
|
|
@ -8,77 +8,77 @@
|
|||
import { Ecs } from '../../../common/ecs';
|
||||
import { TimelineNonEcsData } from '../../../common/search_strategy';
|
||||
|
||||
export const mockEcsDataWithAlert: Ecs = {
|
||||
_id: '1',
|
||||
timestamp: '2018-11-05T19:03:25.937Z',
|
||||
host: {
|
||||
name: ['apache'],
|
||||
ip: ['192.168.0.1'],
|
||||
},
|
||||
event: {
|
||||
id: ['1'],
|
||||
action: ['Action'],
|
||||
category: ['Access'],
|
||||
module: ['nginx'],
|
||||
severity: [3],
|
||||
},
|
||||
source: {
|
||||
ip: ['192.168.0.1'],
|
||||
port: [80],
|
||||
},
|
||||
destination: {
|
||||
ip: ['192.168.0.3'],
|
||||
port: [6343],
|
||||
},
|
||||
user: {
|
||||
id: ['1'],
|
||||
name: ['john.dee'],
|
||||
},
|
||||
geo: {
|
||||
region_name: ['xx'],
|
||||
country_iso_code: ['xx'],
|
||||
},
|
||||
signal: {
|
||||
rule: {
|
||||
created_at: ['2020-01-10T21:11:45.839Z'],
|
||||
updated_at: ['2020-01-10T21:11:45.839Z'],
|
||||
created_by: ['elastic'],
|
||||
description: ['24/7'],
|
||||
enabled: [true],
|
||||
false_positives: ['test-1'],
|
||||
filters: [],
|
||||
from: ['now-300s'],
|
||||
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
|
||||
immutable: [false],
|
||||
index: ['auditbeat-*'],
|
||||
interval: ['5m'],
|
||||
rule_id: ['rule-id-1'],
|
||||
language: ['kuery'],
|
||||
output_index: ['.siem-signals-default'],
|
||||
max_signals: [100],
|
||||
risk_score: ['21'],
|
||||
query: ['user.name: root or user.name: admin'],
|
||||
references: ['www.test.co'],
|
||||
saved_id: ["Garrett's IP"],
|
||||
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
|
||||
timeline_title: ['Untitled timeline'],
|
||||
severity: ['low'],
|
||||
updated_by: ['elastic'],
|
||||
tags: [],
|
||||
to: ['now'],
|
||||
type: ['saved_query'],
|
||||
threat: [],
|
||||
note: ['# this is some markdown documentation'],
|
||||
version: ['1'],
|
||||
export const getDetectionAlertMock = (overrides: Partial<Ecs> = {}): Ecs => ({
|
||||
...{
|
||||
_id: '1',
|
||||
timestamp: '2018-11-05T19:03:25.937Z',
|
||||
host: {
|
||||
name: ['apache'],
|
||||
ip: ['192.168.0.1'],
|
||||
},
|
||||
event: {
|
||||
id: ['1'],
|
||||
action: ['Action'],
|
||||
category: ['Access'],
|
||||
module: ['nginx'],
|
||||
severity: [3],
|
||||
},
|
||||
source: {
|
||||
ip: ['192.168.0.1'],
|
||||
port: [80],
|
||||
},
|
||||
destination: {
|
||||
ip: ['192.168.0.3'],
|
||||
port: [6343],
|
||||
},
|
||||
user: {
|
||||
id: ['1'],
|
||||
name: ['john.dee'],
|
||||
},
|
||||
geo: {
|
||||
region_name: ['xx'],
|
||||
country_iso_code: ['xx'],
|
||||
},
|
||||
signal: {
|
||||
rule: {
|
||||
created_at: ['2020-01-10T21:11:45.839Z'],
|
||||
updated_at: ['2020-01-10T21:11:45.839Z'],
|
||||
created_by: ['elastic'],
|
||||
description: ['24/7'],
|
||||
enabled: [true],
|
||||
false_positives: ['test-1'],
|
||||
filters: [],
|
||||
from: ['now-300s'],
|
||||
id: ['b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea'],
|
||||
immutable: [false],
|
||||
index: ['auditbeat-*'],
|
||||
interval: ['5m'],
|
||||
rule_id: ['rule-id-1'],
|
||||
language: ['kuery'],
|
||||
output_index: ['.siem-signals-default'],
|
||||
max_signals: [100],
|
||||
risk_score: ['21'],
|
||||
query: ['user.name: root or user.name: admin'],
|
||||
references: ['www.test.co'],
|
||||
saved_id: ["Garrett's IP"],
|
||||
timeline_id: ['1234-2136-11ea-9864-ebc8cc1cb8c2'],
|
||||
timeline_title: ['Untitled timeline'],
|
||||
severity: ['low'],
|
||||
updated_by: ['elastic'],
|
||||
tags: [],
|
||||
to: ['now'],
|
||||
type: ['saved_query'],
|
||||
threat: [],
|
||||
note: ['# this is some markdown documentation'],
|
||||
version: ['1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getDetectionAlertMock = (overrides: Partial<Ecs> = {}): Ecs => ({
|
||||
...mockEcsDataWithAlert,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
export const mockEcsDataWithAlert: Ecs = getDetectionAlertMock();
|
||||
|
||||
export const getThreatMatchDetectionAlert = (overrides: Partial<Ecs> = {}): Ecs => ({
|
||||
...mockEcsDataWithAlert,
|
||||
signal: {
|
||||
|
|
|
@ -17,6 +17,7 @@ import { ThemeProvider } from 'styled-components';
|
|||
import { Capabilities } from '@kbn/core/public';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
|
||||
import { ConsoleManager } from '../../management/components/console';
|
||||
import { createStore, State } from '../store';
|
||||
import { mockGlobalState } from './global_state';
|
||||
import {
|
||||
|
@ -59,7 +60,9 @@ export const TestProvidersComponent: React.FC<Props> = ({
|
|||
<ReduxStoreProvider store={store}>
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
|
||||
<ConsoleManager>
|
||||
<DragDropContext onDragEnd={onDragEnd}>{children}</DragDropContext>
|
||||
</ConsoleManager>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ReduxStoreProvider>
|
||||
|
|
|
@ -9,44 +9,10 @@ import { mount } from 'enzyme';
|
|||
import React from 'react';
|
||||
|
||||
import { HostsTableType } from '../../../hosts/store/model';
|
||||
import { RouteSpyState } from './types';
|
||||
import { ManageRoutesSpy } from './manage_spy_routes';
|
||||
import { SpyRouteComponent } from './spy_routes';
|
||||
import { useRouteSpy } from './use_route_spy';
|
||||
|
||||
type Action = 'PUSH' | 'POP' | 'REPLACE';
|
||||
const pop: Action = 'POP';
|
||||
|
||||
const defaultLocation = {
|
||||
hash: '',
|
||||
pathname: '/hosts',
|
||||
search: '',
|
||||
state: '',
|
||||
};
|
||||
|
||||
export const mockHistory = {
|
||||
action: pop,
|
||||
block: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
go: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
length: 2,
|
||||
listen: jest.fn(),
|
||||
location: defaultLocation,
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
};
|
||||
|
||||
const dispatchMock = jest.fn();
|
||||
const mockRoutes: RouteSpyState = {
|
||||
pageName: '',
|
||||
detailName: undefined,
|
||||
tabName: undefined,
|
||||
search: '',
|
||||
pathName: '/',
|
||||
history: mockHistory,
|
||||
};
|
||||
import { generateHistoryMock, generateRoutesMock } from './mocks';
|
||||
|
||||
const mockUseRouteSpy: jest.Mock = useRouteSpy as jest.Mock;
|
||||
jest.mock('./use_route_spy', () => ({
|
||||
|
@ -54,19 +20,25 @@ jest.mock('./use_route_spy', () => ({
|
|||
}));
|
||||
|
||||
describe('Spy Routes', () => {
|
||||
let mockRoutes: ReturnType<typeof generateRoutesMock>;
|
||||
let mockHistoryValue: ReturnType<typeof generateHistoryMock>;
|
||||
let dispatchMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRoutes = generateRoutesMock();
|
||||
mockHistoryValue = generateHistoryMock();
|
||||
dispatchMock = jest.fn();
|
||||
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
|
||||
});
|
||||
|
||||
describe('At Initialization of the app', () => {
|
||||
beforeEach(() => {
|
||||
dispatchMock.mockReset();
|
||||
dispatchMock.mockClear();
|
||||
});
|
||||
test('Make sure we update search state first', () => {
|
||||
test('Make sure we update search state first', async () => {
|
||||
const pathname = '/';
|
||||
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
|
||||
mount(
|
||||
<ManageRoutesSpy>
|
||||
<SpyRouteComponent
|
||||
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
|
||||
history={mockHistory}
|
||||
history={mockHistoryValue}
|
||||
match={{
|
||||
isExact: false,
|
||||
path: pathname,
|
||||
|
@ -93,12 +65,11 @@ describe('Spy Routes', () => {
|
|||
|
||||
test('Make sure we update search state first and then update the route but keeping the initial search', () => {
|
||||
const pathname = '/hosts/allHosts';
|
||||
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
|
||||
mount(
|
||||
<ManageRoutesSpy>
|
||||
<SpyRouteComponent
|
||||
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
|
||||
history={mockHistory}
|
||||
history={mockHistoryValue}
|
||||
match={{
|
||||
isExact: false,
|
||||
path: pathname,
|
||||
|
@ -127,7 +98,7 @@ describe('Spy Routes', () => {
|
|||
route: {
|
||||
pageName: 'hosts',
|
||||
detailName: undefined,
|
||||
history: mockHistory,
|
||||
history: mockHistoryValue,
|
||||
pathName: pathname,
|
||||
tabName: HostsTableType.hosts,
|
||||
},
|
||||
|
@ -138,18 +109,13 @@ describe('Spy Routes', () => {
|
|||
});
|
||||
|
||||
describe('When app is running', () => {
|
||||
beforeEach(() => {
|
||||
dispatchMock.mockReset();
|
||||
dispatchMock.mockClear();
|
||||
});
|
||||
test('Update route should be updated when there is changed detected', () => {
|
||||
const pathname = '/hosts/allHosts';
|
||||
const newPathname = `hosts/${HostsTableType.authentications}`;
|
||||
mockUseRouteSpy.mockImplementation(() => [mockRoutes, dispatchMock]);
|
||||
const wrapper = mount(
|
||||
<SpyRouteComponent
|
||||
location={{ hash: '', pathname, search: '?importantQueryString="really"', state: '' }}
|
||||
history={mockHistory}
|
||||
history={mockHistoryValue}
|
||||
match={{
|
||||
isExact: false,
|
||||
path: pathname,
|
||||
|
@ -195,7 +161,7 @@ describe('Spy Routes', () => {
|
|||
{
|
||||
route: {
|
||||
detailName: undefined,
|
||||
history: mockHistory,
|
||||
history: mockHistoryValue,
|
||||
pageName: 'hosts',
|
||||
pathName: newPathname,
|
||||
tabName: HostsTableType.authentications,
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { RouteSpyState } from './types';
|
||||
|
||||
type Action = 'PUSH' | 'POP' | 'REPLACE';
|
||||
|
||||
const pop: Action = 'POP';
|
||||
|
||||
const generateDefaultLocationMock = () => ({
|
||||
hash: '',
|
||||
pathname: '/hosts',
|
||||
search: '',
|
||||
state: '',
|
||||
});
|
||||
|
||||
export const generateHistoryMock = () => ({
|
||||
action: pop,
|
||||
block: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
go: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
length: 2,
|
||||
listen: jest.fn(),
|
||||
location: generateDefaultLocationMock(),
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
});
|
||||
|
||||
export const mockHistory = generateHistoryMock();
|
||||
|
||||
export const generateRoutesMock = (): RouteSpyState => ({
|
||||
pageName: '',
|
||||
detailName: undefined,
|
||||
tabName: undefined,
|
||||
search: '',
|
||||
pathName: '/',
|
||||
history: mockHistory,
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { useResponseActionsConsoleActionItem } from './use_response_actions_console_action_item';
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiContextMenuItem } from '@elastic/eui';
|
||||
import React, { memo, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
useGetEndpointDetails,
|
||||
useShowEndpointResponseActionsConsole,
|
||||
} from '../../../management/hooks';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
|
||||
export const NOT_FROM_ENDPOINT_HOST_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.notSupportedTooltip',
|
||||
{ defaultMessage: 'The current item does not support endpoint response actions' }
|
||||
);
|
||||
export const HOST_ENDPOINT_UNENROLLED_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.unenrolledTooltip',
|
||||
{ defaultMessage: 'Host is no longer enrolled with endpoint security' }
|
||||
);
|
||||
export const LOADING_ENDPOINT_DATA_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.loadingTooltip',
|
||||
{ defaultMessage: 'Loading' }
|
||||
);
|
||||
|
||||
export interface ResponseActionsConsoleContextMenuItemProps {
|
||||
endpointId: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const ResponseActionsConsoleContextMenuItem =
|
||||
memo<ResponseActionsConsoleContextMenuItemProps>(({ endpointId, onClick }) => {
|
||||
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
|
||||
const {
|
||||
data: endpointHostInfo,
|
||||
isFetching,
|
||||
error,
|
||||
} = useGetEndpointDetails(endpointId, { enabled: Boolean(endpointId) });
|
||||
|
||||
const [isDisabled, tooltip]: [disabled: boolean, tooltip: ReactNode] = useMemo(() => {
|
||||
if (!endpointId) {
|
||||
return [true, NOT_FROM_ENDPOINT_HOST_TOOLTIP];
|
||||
}
|
||||
|
||||
// Still loading Endpoint host info
|
||||
if (isFetching) {
|
||||
return [true, LOADING_ENDPOINT_DATA_TOOLTIP];
|
||||
}
|
||||
|
||||
// if we got an error and it's a 404 (alerts can exist for endpoint that are no longer around)
|
||||
// or,
|
||||
// the Host status is `unenrolled`
|
||||
if (
|
||||
(error && error.body.statusCode === 404) ||
|
||||
endpointHostInfo?.host_status === HostStatus.UNENROLLED
|
||||
) {
|
||||
return [true, HOST_ENDPOINT_UNENROLLED_TOOLTIP];
|
||||
}
|
||||
|
||||
return [false, undefined];
|
||||
}, [endpointHostInfo?.host_status, endpointId, error, isFetching]);
|
||||
|
||||
const handleResponseActionsClick = useCallback(() => {
|
||||
if (endpointHostInfo) showEndpointResponseActionsConsole(endpointHostInfo.metadata);
|
||||
if (onClick) onClick();
|
||||
}, [endpointHostInfo, onClick, showEndpointResponseActionsConsole]);
|
||||
|
||||
return (
|
||||
<EuiContextMenuItem
|
||||
key="endpointResponseActions-action-item"
|
||||
data-test-subj="endpointResponseActions-action-item"
|
||||
disabled={isDisabled}
|
||||
toolTipContent={tooltip}
|
||||
size="s"
|
||||
onClick={handleResponseActionsClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.detections.takeAction.responseActionConsole.buttonLabel"
|
||||
defaultMessage="Launch responder"
|
||||
/>
|
||||
</EuiContextMenuItem>
|
||||
);
|
||||
});
|
||||
ResponseActionsConsoleContextMenuItem.displayName = 'ResponseActionsConsoleContextMenuItem';
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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, { useMemo } from 'react';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
import { isAlertFromEndpointEvent } from '../../../common/utils/endpoint_alert_check';
|
||||
import { ResponseActionsConsoleContextMenuItem } from './response_actions_console_context_menu_item';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
import { getFieldValue } from '../host_isolation/helpers';
|
||||
|
||||
export const useResponseActionsConsoleActionItem = (
|
||||
eventDetailsData: TimelineEventsDetailsItem[] | null,
|
||||
onClick: () => void
|
||||
): JSX.Element[] => {
|
||||
const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled(
|
||||
'responseActionsConsoleEnabled'
|
||||
);
|
||||
const { loading: isAuthzLoading, canAccessEndpointManagement } =
|
||||
useUserPrivileges().endpointPrivileges;
|
||||
|
||||
const isEndpointAlert = useMemo(() => {
|
||||
return isAlertFromEndpointEvent({ data: eventDetailsData || [] });
|
||||
}, [eventDetailsData]);
|
||||
|
||||
const endpointId = useMemo(
|
||||
() => getFieldValue({ category: 'agent', field: 'agent.id' }, eventDetailsData),
|
||||
[eventDetailsData]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const actions: JSX.Element[] = [];
|
||||
|
||||
if (isResponseActionsConsoleEnabled && !isAuthzLoading && canAccessEndpointManagement) {
|
||||
actions.push(
|
||||
<ResponseActionsConsoleContextMenuItem
|
||||
endpointId={isEndpointAlert ? endpointId : ''}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}, [
|
||||
canAccessEndpointManagement,
|
||||
endpointId,
|
||||
isAuthzLoading,
|
||||
isEndpointAlert,
|
||||
isResponseActionsConsoleEnabled,
|
||||
onClick,
|
||||
]);
|
||||
};
|
|
@ -9,27 +9,40 @@ import { mount, ReactWrapper } from 'enzyme';
|
|||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { TakeActionDropdown, TakeActionDropdownProps } from '.';
|
||||
import { mockAlertDetailsData } from '../../../common/components/event_details/__mocks__';
|
||||
import { mockEcsDataWithAlert } from '../../../common/mock/mock_detection_alerts';
|
||||
import { generateAlertDetailsDataMock } from '../../../common/components/event_details/__mocks__';
|
||||
import { getDetectionAlertMock } from '../../../common/mock/mock_detection_alerts';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { TimelineId } from '../../../../common/types';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { mockTimelines } from '../../../common/mock/mock_timelines_plugin';
|
||||
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { useKibana, useGetUserCasesPermissions, useHttp } from '../../../common/lib/kibana';
|
||||
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
|
||||
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../common/components/user_privileges/user_privileges_context';
|
||||
import { useUserPrivileges } from '../../../common/components/user_privileges';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
import {
|
||||
HOST_ENDPOINT_UNENROLLED_TOOLTIP,
|
||||
NOT_FROM_ENDPOINT_HOST_TOOLTIP,
|
||||
} from '../response_actions_console/response_actions_console_context_menu_item';
|
||||
import { endpointMetadataHttpMocks } from '../../../management/pages/endpoint_hosts/mocks';
|
||||
import { HttpSetup } from '@kbn/core/public';
|
||||
import {
|
||||
isAlertFromEndpointEvent,
|
||||
isAlertFromEndpointAlert,
|
||||
} from '../../../common/utils/endpoint_alert_check';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
import { getUserPrivilegesMockDefaultValue } from '../../../common/components/user_privileges/__mocks__';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
jest.mock('../user_info', () => ({
|
||||
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
|
||||
}));
|
||||
jest.mock('../../../common/lib/kibana', () => ({
|
||||
useKibana: jest.fn(),
|
||||
useGetUserCasesPermissions: jest.fn().mockReturnValue({ crud: true }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: true });
|
||||
|
||||
jest.mock('../../containers/detection_engine/alerts/use_alerts_privileges', () => ({
|
||||
useAlertsPrivileges: jest.fn().mockReturnValue({ hasIndexWrite: true, hasKibanaCRUD: true }),
|
||||
}));
|
||||
|
@ -71,26 +84,29 @@ jest.mock('../../containers/detection_engine/alerts/use_host_isolation_status',
|
|||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
describe('take action dropdown', () => {
|
||||
const defaultProps: TakeActionDropdownProps = {
|
||||
detailsData: mockAlertDetailsData as TimelineEventsDetailsItem[],
|
||||
ecsData: mockEcsDataWithAlert,
|
||||
handleOnEventClosed: jest.fn(),
|
||||
indexName: 'index',
|
||||
isHostIsolationPanelOpen: false,
|
||||
loadingEventDetails: false,
|
||||
onAddEventFilterClick: jest.fn(),
|
||||
onAddExceptionTypeClick: jest.fn(),
|
||||
onAddIsolationStatusClick: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
refetchFlyoutData: jest.fn(),
|
||||
timelineId: TimelineId.active,
|
||||
onOsqueryClick: jest.fn(),
|
||||
};
|
||||
let defaultProps: TakeActionDropdownProps;
|
||||
let mockStartServicesMock: ReturnType<typeof createStartServicesMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultProps = {
|
||||
detailsData: generateAlertDetailsDataMock() as TimelineEventsDetailsItem[],
|
||||
ecsData: getDetectionAlertMock(),
|
||||
handleOnEventClosed: jest.fn(),
|
||||
indexName: 'index',
|
||||
isHostIsolationPanelOpen: false,
|
||||
loadingEventDetails: false,
|
||||
onAddEventFilterClick: jest.fn(),
|
||||
onAddExceptionTypeClick: jest.fn(),
|
||||
onAddIsolationStatusClick: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
refetchFlyoutData: jest.fn(),
|
||||
timelineId: TimelineId.active,
|
||||
onOsqueryClick: jest.fn(),
|
||||
};
|
||||
|
||||
mockStartServicesMock = createStartServicesMock();
|
||||
|
||||
beforeAll(() => {
|
||||
(useKibana as jest.Mock).mockImplementation(() => {
|
||||
const mockStartServicesMock = createStartServicesMock();
|
||||
|
||||
return {
|
||||
services: {
|
||||
...mockStartServicesMock,
|
||||
|
@ -105,6 +121,12 @@ describe('take action dropdown', () => {
|
|||
},
|
||||
};
|
||||
});
|
||||
|
||||
(useHttp as jest.Mock).mockReturnValue(mockStartServicesMock.http);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue(getUserPrivilegesMockDefaultValue());
|
||||
});
|
||||
|
||||
test('should render takeActionButton', () => {
|
||||
|
@ -128,14 +150,12 @@ describe('take action dropdown', () => {
|
|||
});
|
||||
|
||||
describe('should render take action items', () => {
|
||||
const testProps = {
|
||||
...defaultProps,
|
||||
};
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeAll(() => {
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown {...testProps} />
|
||||
<TakeActionDropdown {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
|
@ -207,88 +227,247 @@ describe('take action dropdown', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
test('should render "Launch responder"', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="endpointResponseActions-action-item"]').first().text()
|
||||
).toEqual('Launch responder');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
describe('for Endpoint related actions', () => {
|
||||
/** Removes the detail data that is used to determine if data is for an Alert */
|
||||
const setAlertDetailsDataMockToEvent = () => {
|
||||
if (defaultProps.detailsData) {
|
||||
defaultProps.detailsData = defaultProps.detailsData
|
||||
.map((obj) => {
|
||||
if (obj.field === 'kibana.alert.rule.uuid') {
|
||||
return null;
|
||||
}
|
||||
if (obj.field === 'event.kind') {
|
||||
return {
|
||||
category: 'event',
|
||||
field: 'event.kind',
|
||||
values: ['event'],
|
||||
originalValue: 'event',
|
||||
};
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
.filter((obj) => obj) as TimelineEventsDetailsItem[];
|
||||
} else {
|
||||
expect(defaultProps.detailsData).toBeInstanceOf(Object);
|
||||
}
|
||||
};
|
||||
|
||||
const getEcsDataWithAgentType = (agentType: string) => ({
|
||||
...mockEcsDataWithAlert,
|
||||
agent: {
|
||||
type: [agentType],
|
||||
},
|
||||
const setAlertDetailsDataMockToEndpointAgent = () => {
|
||||
if (defaultProps.detailsData) {
|
||||
defaultProps.detailsData = defaultProps.detailsData.map((obj) => {
|
||||
if (obj.field === 'agent.type') {
|
||||
return {
|
||||
category: 'agent',
|
||||
field: 'agent.type',
|
||||
values: ['endpoint'],
|
||||
originalValue: ['endpoint'],
|
||||
};
|
||||
}
|
||||
if (obj.field === 'agent.id') {
|
||||
return {
|
||||
category: 'agent',
|
||||
field: 'agent.id',
|
||||
values: ['123'],
|
||||
originalValue: ['123'],
|
||||
};
|
||||
}
|
||||
|
||||
return obj;
|
||||
}) as TimelineEventsDetailsItem[];
|
||||
} else {
|
||||
expect(defaultProps.detailsData).toBeInstanceOf(Object);
|
||||
}
|
||||
};
|
||||
|
||||
/** Set the `agent.type` and `agent.id` on the EcsData */
|
||||
const setTypeOnEcsDataWithAgentType = (
|
||||
agentType: string = 'endpoint',
|
||||
agentId: string = '123'
|
||||
) => {
|
||||
if (defaultProps.ecsData) {
|
||||
defaultProps.ecsData.agent = {
|
||||
// @ts-expect-error Ecs definition for agent seems to be missing properties
|
||||
id: agentId,
|
||||
type: [agentType],
|
||||
};
|
||||
} else {
|
||||
expect(defaultProps.ecsData).toBeInstanceOf(Object);
|
||||
}
|
||||
};
|
||||
|
||||
describe('should correctly enable/disable the "Add Endpoint event filter" button', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
setTypeOnEcsDataWithAgentType();
|
||||
setAlertDetailsDataMockToEvent();
|
||||
});
|
||||
|
||||
test('should enable the "Add Endpoint event filter" button if provided endpoint event', async () => {
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should disable the "Add Endpoint event filter" button if no endpoint management privileges', async () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
|
||||
});
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => {
|
||||
setTypeOnEcsDataWithAgentType('filebeat');
|
||||
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const modifiedMockDetailsData = mockAlertDetailsData
|
||||
.map((obj) => {
|
||||
if (obj.field === 'kibana.alert.rule.uuid') {
|
||||
return null;
|
||||
describe('should correctly enable/disable the "Launch responder" button', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
let apiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
|
||||
|
||||
const render = (): ReactWrapper => {
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const findLaunchResponderButton = (): ReturnType<typeof wrapper.find> => {
|
||||
return wrapper.find('[data-test-subj="endpointResponseActions-action-item"]');
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// Un-Mock endpoint alert check hooks
|
||||
const actualChecks = jest.requireActual('../../../common/utils/endpoint_alert_check');
|
||||
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(
|
||||
actualChecks.isAlertFromEndpointEvent
|
||||
);
|
||||
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(
|
||||
actualChecks.isAlertFromEndpointAlert
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Set the mock modules back to what they were
|
||||
(isAlertFromEndpointEvent as jest.Mock).mockImplementation(() => true);
|
||||
(isAlertFromEndpointAlert as jest.Mock).mockImplementation(() => true);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setTypeOnEcsDataWithAgentType();
|
||||
apiMocks = endpointMetadataHttpMocks(mockStartServicesMock.http as jest.Mocked<HttpSetup>);
|
||||
});
|
||||
|
||||
describe('when the `responseActionsConsoleEnabled` feature flag is false', () => {
|
||||
beforeAll(() => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation((featureKey) => {
|
||||
if (featureKey === 'responseActionsConsoleEnabled') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it('should hide the button if feature flag if off', async () => {
|
||||
render();
|
||||
|
||||
expect(findLaunchResponderButton()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display the button if user is not allowed to manage endpoints', async () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
|
||||
});
|
||||
render();
|
||||
|
||||
expect(findLaunchResponderButton()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should disable the button if alert NOT from a host running endpoint', async () => {
|
||||
setTypeOnEcsDataWithAgentType('filebeat');
|
||||
if (defaultProps.detailsData) {
|
||||
defaultProps.detailsData = generateAlertDetailsDataMock() as TimelineEventsDetailsItem[];
|
||||
}
|
||||
if (obj.field === 'event.kind') {
|
||||
return {
|
||||
category: 'event',
|
||||
field: 'event.kind',
|
||||
values: ['event'],
|
||||
originalValue: 'event',
|
||||
};
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
.filter((obj) => obj) as TimelineEventsDetailsItem[];
|
||||
render();
|
||||
|
||||
test('should enable the "Add Endpoint event filter" button if provided endpoint event', async () => {
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown
|
||||
{...defaultProps}
|
||||
detailsData={modifiedMockDetailsData}
|
||||
ecsData={getEcsDataWithAgentType('endpoint')}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
const consoleButton = findLaunchResponderButton().first();
|
||||
|
||||
test('should disable the "Add Endpoint event filter" button if no endpoint management privileges', async () => {
|
||||
(useUserPrivileges as jest.Mock).mockReturnValue({
|
||||
...mockInitialUserPrivilegesState(),
|
||||
endpointPrivileges: { loading: false, canAccessEndpointManagement: false },
|
||||
expect(consoleButton.prop('disabled')).toBe(true);
|
||||
expect(consoleButton.prop('toolTipContent')).toEqual(NOT_FROM_ENDPOINT_HOST_TOOLTIP);
|
||||
});
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown
|
||||
{...defaultProps}
|
||||
detailsData={modifiedMockDetailsData}
|
||||
ecsData={getEcsDataWithAgentType('endpoint')}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="add-event-filter-menu-item"]').first().getDOMNode()
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide the "Add Endpoint event filter" button if provided no event from endpoint', async () => {
|
||||
wrapper = mount(
|
||||
<TestProviders>
|
||||
<TakeActionDropdown
|
||||
{...defaultProps}
|
||||
detailsData={modifiedMockDetailsData}
|
||||
ecsData={getEcsDataWithAgentType('filesbeat')}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
wrapper.find('button[data-test-subj="take-action-dropdown-btn"]').simulate('click');
|
||||
await waitFor(() => {
|
||||
expect(wrapper.exists('[data-test-subj="add-event-filter-menu-item"]')).toBeFalsy();
|
||||
it('should disable the button if host status is unenrolled', async () => {
|
||||
setAlertDetailsDataMockToEndpointAgent();
|
||||
const getApiResponse = apiMocks.responseProvider.metadataDetails.getMockImplementation();
|
||||
apiMocks.responseProvider.metadataDetails.mockImplementation(() => {
|
||||
if (getApiResponse) {
|
||||
return {
|
||||
...getApiResponse(),
|
||||
host_status: HostStatus.UNENROLLED,
|
||||
};
|
||||
}
|
||||
throw new Error('mock implementation missing');
|
||||
});
|
||||
render();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
wrapper.update();
|
||||
|
||||
expect(findLaunchResponderButton().first().prop('disabled')).toBe(true);
|
||||
expect(findLaunchResponderButton().first().prop('toolTipContent')).toEqual(
|
||||
HOST_ENDPOINT_UNENROLLED_TOOLTIP
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiButton, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types';
|
||||
import { useResponseActionsConsoleActionItem } from '../response_actions_console';
|
||||
import { TimelineEventsDetailsItem } from '../../../../common/search_strategy';
|
||||
import { TAKE_ACTION } from '../alerts_table/alerts_utility_bar/translations';
|
||||
import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
|
||||
|
@ -135,6 +136,11 @@ export const TakeActionDropdown = React.memo(
|
|||
isHostIsolationPanelOpen,
|
||||
});
|
||||
|
||||
const endpointResponseActionsConsoleItems = useResponseActionsConsoleActionItem(
|
||||
detailsData,
|
||||
closePopoverHandler
|
||||
);
|
||||
|
||||
const handleOnAddExceptionTypeClick = useCallback(
|
||||
(type: ExceptionListType) => {
|
||||
onAddExceptionTypeClick(type);
|
||||
|
@ -223,6 +229,7 @@ export const TakeActionDropdown = React.memo(
|
|||
...(tGridEnabled ? addToCaseActionItems : []),
|
||||
...alertsActionItems,
|
||||
...hostIsolationActionItems,
|
||||
...endpointResponseActionsConsoleItems,
|
||||
...(osqueryAvailable ? [osqueryActionItem] : []),
|
||||
...investigateInTimelineActionItems,
|
||||
],
|
||||
|
@ -231,6 +238,7 @@ export const TakeActionDropdown = React.memo(
|
|||
addToCaseActionItems,
|
||||
alertsActionItems,
|
||||
hostIsolationActionItems,
|
||||
endpointResponseActionsConsoleItems,
|
||||
osqueryAvailable,
|
||||
osqueryActionItem,
|
||||
investigateInTimelineActionItems,
|
||||
|
|
|
@ -9,7 +9,6 @@ import React from 'react';
|
|||
import { mount } from 'enzyme';
|
||||
|
||||
import { TestProviders } from '../../../../../../common/mock';
|
||||
import { mockHistory } from '../../../../../../common/utils/route/index.test';
|
||||
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
|
||||
import { useUserData } from '../../../../../components/user_info';
|
||||
|
||||
|
@ -17,6 +16,7 @@ import { ExceptionListsTable } from './exceptions_table';
|
|||
import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks';
|
||||
import { useAllExceptionLists } from './use_all_exception_lists';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { generateHistoryMock } from '../../../../../../common/utils/route/mocks';
|
||||
|
||||
jest.mock('../../../../../components/user_info');
|
||||
jest.mock('../../../../../../common/lib/kibana');
|
||||
|
@ -44,6 +44,7 @@ jest.mock('../../../../../containers/detection_engine/lists/use_lists_config', (
|
|||
}));
|
||||
|
||||
describe('ExceptionListsTable', () => {
|
||||
const mockHistory = generateHistoryMock();
|
||||
const exceptionList1 = getExceptionListSchemaMock();
|
||||
const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' };
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { render as _render, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useFirstLastSeenHost } from '../../containers/hosts/first_last_seen';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
|
@ -26,38 +26,46 @@ describe('FirstLastSeen Component', () => {
|
|||
const firstSeen = 'Apr 8, 2019 @ 16:09:40.692';
|
||||
const lastSeen = 'Apr 8, 2019 @ 18:35:45.064';
|
||||
|
||||
let render: (ui: React.ReactElement) => ReturnType<typeof _render>;
|
||||
|
||||
beforeEach(() => {
|
||||
render = (ui: React.ReactElement): ReturnType<typeof _render> => {
|
||||
return _render(
|
||||
<TestProviders>
|
||||
<div data-test-subj="test-render-output">{ui}</div>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
test('Loading', async () => {
|
||||
useFirstLastSeenHostMock.mockReturnValue([true, MOCKED_RESPONSE]);
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
</TestProviders>
|
||||
const { getByTestId } = render(
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
);
|
||||
expect(container.innerHTML).toBe(
|
||||
expect(getByTestId('test-render-output').innerHTML).toBe(
|
||||
'<span class="euiLoadingSpinner euiLoadingSpinner--medium"></span>'
|
||||
);
|
||||
});
|
||||
|
||||
test('First Seen', async () => {
|
||||
useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]);
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
</TestProviders>
|
||||
const { getByTestId } = render(
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe(
|
||||
expect(getByTestId('test-render-output').innerHTML).toBe(
|
||||
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${firstSeen}</span></div>`
|
||||
);
|
||||
});
|
||||
|
@ -65,18 +73,16 @@ describe('FirstLastSeen Component', () => {
|
|||
|
||||
test('Last Seen', async () => {
|
||||
useFirstLastSeenHostMock.mockReturnValue([false, MOCKED_RESPONSE]);
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.LAST_SEEN}
|
||||
/>
|
||||
</TestProviders>
|
||||
const { getByTestId } = render(
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.LAST_SEEN}
|
||||
/>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe(
|
||||
expect(getByTestId('test-render-output').innerHTML).toBe(
|
||||
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${lastSeen}</span></div>`
|
||||
);
|
||||
});
|
||||
|
@ -90,19 +96,17 @@ describe('FirstLastSeen Component', () => {
|
|||
firstSeen: null,
|
||||
},
|
||||
]);
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.LAST_SEEN}
|
||||
/>
|
||||
</TestProviders>
|
||||
const { getByTestId } = render(
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.LAST_SEEN}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe(
|
||||
expect(getByTestId('test-render-output').innerHTML).toBe(
|
||||
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${lastSeen}</span></div>`
|
||||
);
|
||||
});
|
||||
|
@ -116,19 +120,17 @@ describe('FirstLastSeen Component', () => {
|
|||
lastSeen: null,
|
||||
},
|
||||
]);
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
</TestProviders>
|
||||
const { getByTestId } = render(
|
||||
<FirstLastSeenHost
|
||||
docValueFields={[]}
|
||||
indexNames={[]}
|
||||
hostName="kibana-siem"
|
||||
type={FirstLastSeenHostType.FIRST_SEEN}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.innerHTML).toBe(
|
||||
expect(getByTestId('test-render-output').innerHTML).toBe(
|
||||
`<div class="euiText euiText--small"><span class="euiToolTipAnchor">${firstSeen}</span></div>`
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
|
||||
export interface CommandExecutionFailureProps {
|
||||
error: Error;
|
||||
}
|
||||
export const CommandExecutionFailure = memo<CommandExecutionFailureProps>(({ error }) => {
|
||||
return <EuiCallOut>{error}</EuiCallOut>;
|
||||
});
|
||||
CommandExecutionFailure.displayName = 'CommandExecutionOutput';
|
|
@ -8,6 +8,7 @@
|
|||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { EuiLoadingChart } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import type { CommandExecutionComponentProps } from '../types';
|
||||
import type { CommandExecutionState, CommandHistoryItem } from './console_state/types';
|
||||
import { UserCommandInput } from './user_command_input';
|
||||
import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch';
|
||||
|
@ -43,13 +44,13 @@ export const CommandExecutionOutput = memo<CommandExecutionOutputProps>(
|
|||
);
|
||||
|
||||
/** Updates the Command's execution store */
|
||||
const setCommandStore = useCallback(
|
||||
(store) => {
|
||||
const setCommandStore: CommandExecutionComponentProps['setStore'] = useCallback(
|
||||
(updateStoreFn) => {
|
||||
dispatch({
|
||||
type: 'updateCommandStoreState',
|
||||
payload: {
|
||||
id,
|
||||
value: store,
|
||||
value: updateStoreFn,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -22,11 +22,11 @@ import { ConfirmTerminate } from './confirm_terminate';
|
|||
|
||||
const ConsolePopupWrapper = styled.div`
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
right: 0;
|
||||
min-height: 60vh;
|
||||
min-width: 40vw;
|
||||
max-width: 70vw;
|
||||
bottom: 60px;
|
||||
left: 20px;
|
||||
height: 50vh;
|
||||
width: 48vw;
|
||||
max-width: 90vw;
|
||||
|
||||
&.is-hidden {
|
||||
display: none;
|
||||
|
|
|
@ -248,11 +248,7 @@ describe('When using ConsoleManager', () => {
|
|||
const mockedContext = createAppRootMockRenderer();
|
||||
|
||||
render = async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<ConsoleManager>
|
||||
<ConsoleManagerTestComponent />
|
||||
</ConsoleManager>
|
||||
);
|
||||
renderResult = mockedContext.render(<ConsoleManagerTestComponent />);
|
||||
|
||||
clickOnRegisterNewConsole();
|
||||
|
||||
|
|
|
@ -5,11 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
|
||||
import { ConsoleRegistrationInterface, RegisteredConsoleClient } from './types';
|
||||
import { useConsoleManager } from './console_manager';
|
||||
import { act } from '@testing-library/react-hooks';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { AppContextTestRender } from '../../../../../common/mock/endpoint';
|
||||
import { getCommandListMock } from '../../mocks';
|
||||
import { useConsoleManager } from './console_manager';
|
||||
import { ConsoleRegistrationInterface, RegisteredConsoleClient } from './types';
|
||||
|
||||
export const getNewConsoleRegistrationMock = (
|
||||
overrides: Partial<ConsoleRegistrationInterface> = {}
|
||||
|
@ -27,6 +33,65 @@ export const getNewConsoleRegistrationMock = (
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of queries and user action helper methods for interacting with the ConsoleManager test component
|
||||
* and ConsoleManager
|
||||
* @param renderResult
|
||||
*/
|
||||
export const getConsoleManagerMockRenderResultQueriesAndActions = (
|
||||
renderResult: ReturnType<AppContextTestRender['render']>
|
||||
) => {
|
||||
return {
|
||||
/**
|
||||
* Clicks the button to register a new console. The new console registration is shown in the list of running consoles
|
||||
*/
|
||||
clickOnRegisterNewConsole: async () => {
|
||||
const currentRunningCount = renderResult.queryAllByTestId('showRunningConsole').length;
|
||||
|
||||
act(() => {
|
||||
userEvent.click(renderResult.getByTestId('registerNewConsole'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderResult.queryAllByTestId('showRunningConsole')).toHaveLength(
|
||||
currentRunningCount + 1
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clicks one of the hidden consoles so that it can be shown.
|
||||
* @param atIndex
|
||||
*/
|
||||
openRunningConsole: async (atIndex: number = 0) => {
|
||||
act(() => {
|
||||
userEvent.click(renderResult.queryAllByTestId('showRunningConsole')[atIndex]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
|
||||
).toBe(false);
|
||||
});
|
||||
},
|
||||
|
||||
hideOpenedConsole: async () => {
|
||||
const hideConsoleButton = renderResult.queryByTestId('consolePopupHideButton');
|
||||
|
||||
if (!hideConsoleButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
userEvent.click(hideConsoleButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
renderResult.getByTestId('consolePopupWrapper').classList.contains('is-hidden')
|
||||
).toBe(true);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
|
||||
({ registeredConsole }) => {
|
||||
const handleShowOnClick = useCallback(() => {
|
||||
|
@ -40,7 +105,11 @@ const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
|
|||
{registeredConsole.title}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton onClick={handleShowOnClick} data-test-subj="showRunningConsole">
|
||||
<EuiButton
|
||||
onClick={handleShowOnClick}
|
||||
data-test-subj="showRunningConsole"
|
||||
data-console-id={registeredConsole.id}
|
||||
>
|
||||
{'show'}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
|
@ -52,17 +121,26 @@ const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
|
|||
);
|
||||
RunningConsole.displayName = 'RunningConsole';
|
||||
|
||||
export const ConsoleManagerTestComponent = memo(() => {
|
||||
/**
|
||||
* A test component that enables one to open consoles managed via `ConsoleManager`.
|
||||
*/
|
||||
export const ConsoleManagerTestComponent = memo<{
|
||||
/**
|
||||
* A callback that can return the registration (or partial registration) for a new console. These
|
||||
* will be appended to the output generated by `getNewConsoleRegistrationMock()`
|
||||
*/
|
||||
registerConsoleProps?: () => Partial<ConsoleRegistrationInterface>;
|
||||
}>(({ registerConsoleProps = () => ({}) }) => {
|
||||
const consoleManager = useConsoleManager();
|
||||
const handleRegisterNewConsoleOnClick = useCallback(() => {
|
||||
consoleManager.register(getNewConsoleRegistrationMock());
|
||||
}, [consoleManager]);
|
||||
consoleManager.register(getNewConsoleRegistrationMock(registerConsoleProps()));
|
||||
}, [consoleManager, registerConsoleProps]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<EuiButton data-test-subj="registerNewConsole" onClick={handleRegisterNewConsoleOnClick}>
|
||||
{'Register and show new managed console'}
|
||||
{'Register new console'}
|
||||
</EuiButton>
|
||||
</div>
|
||||
<div>
|
||||
|
|
|
@ -37,7 +37,9 @@ export const handleUpdateCommandState: ConsoleStoreReducer<UpdateCommandStateAct
|
|||
|
||||
switch (type) {
|
||||
case 'updateCommandStoreState':
|
||||
updatedCommandState.state.store = value as CommandExecutionState['store'];
|
||||
updatedCommandState.state.store = (
|
||||
value as (prevState: CommandExecutionState['store']) => CommandExecutionState['store']
|
||||
)(updatedCommandState.state.store);
|
||||
break;
|
||||
case 'updateCommandStatusState':
|
||||
// If the status was not changed, then there is nothing to be done here, so
|
||||
|
|
|
@ -44,7 +44,10 @@ export type ConsoleDataAction =
|
|||
| { type: 'clear' }
|
||||
| {
|
||||
type: 'updateCommandStoreState';
|
||||
payload: { id: string; value: CommandExecutionState['store'] };
|
||||
payload: {
|
||||
id: string;
|
||||
value: (prevState: CommandExecutionState['store']) => CommandExecutionState['store'];
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'updateCommandStatusState';
|
||||
|
|
|
@ -109,7 +109,9 @@ export const getCommandListMock = (): CommandDefinition[] => {
|
|||
if (status !== 'success') {
|
||||
new Promise((r) => setTimeout(r, 500)).then(() => {
|
||||
setStatus('success');
|
||||
setStore({ foo: 'bar' });
|
||||
setStore((prevState) => {
|
||||
return { foo: 'bar' };
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [setStatus, setStore, status]);
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ComponentType, ComponentProps } from 'react';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { CommonProps } from '@elastic/eui';
|
||||
import type { CommandExecutionState } from './components/console_state/types';
|
||||
import type { Immutable } from '../../../../common/endpoint/types';
|
||||
import type { Immutable, MaybeImmutable } from '../../../../common/endpoint/types';
|
||||
import type { ParsedArgData, ParsedCommandInput } from './service/parsed_command_input';
|
||||
|
||||
export interface CommandDefinition {
|
||||
export interface CommandDefinition<TMeta = any> {
|
||||
name: string;
|
||||
about: string;
|
||||
/**
|
||||
|
@ -28,7 +30,7 @@ export interface CommandDefinition {
|
|||
* The entire `CommandDefinition` is passed along to the component
|
||||
* that will handle it, so this data will be available there
|
||||
*/
|
||||
meta?: Record<string, unknown>;
|
||||
meta?: TMeta;
|
||||
|
||||
/** If all args are optional, but at least one must be defined, set to true */
|
||||
mustHaveArgs?: boolean;
|
||||
|
@ -54,21 +56,22 @@ export interface CommandDefinition {
|
|||
/**
|
||||
* A command to be executed (as entered by the user)
|
||||
*/
|
||||
export interface Command {
|
||||
export interface Command<TDefinition extends CommandDefinition = CommandDefinition> {
|
||||
/** The raw input entered by the user */
|
||||
input: string;
|
||||
// FIXME:PT this should be a generic that allows for the arguments type to be used
|
||||
/** An object with the arguments entered by the user and their value */
|
||||
args: ParsedCommandInput;
|
||||
/** The command defined associated with this user command */
|
||||
commandDefinition: CommandDefinition;
|
||||
/** The command definition associated with this user command */
|
||||
commandDefinition: TDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* The component that will handle the Command execution and display the result.
|
||||
*/
|
||||
export type CommandExecutionComponent = ComponentType<{
|
||||
command: Command;
|
||||
export interface CommandExecutionComponentProps<
|
||||
TStore extends object = Record<string, unknown>,
|
||||
TMeta = any
|
||||
> {
|
||||
command: Command<CommandDefinition<TMeta>>;
|
||||
|
||||
/**
|
||||
* A data store for the command execution to store data in, if needed.
|
||||
* Because the Console could be closed/opened several times, which will cause this component
|
||||
|
@ -76,9 +79,15 @@ export type CommandExecutionComponent = ComponentType<{
|
|||
* persisting data (ex. API response with IDs) that the command can use to determine
|
||||
* if the command has already been executed or if it's a new instance.
|
||||
*/
|
||||
store: Immutable<CommandExecutionState['store']>;
|
||||
/** Sets the `store` data above */
|
||||
setStore: (state: CommandExecutionState['store']) => void;
|
||||
store: Immutable<Partial<TStore>>;
|
||||
|
||||
/**
|
||||
* Sets the `store` data above. Function will be called the latest (prevState) store data
|
||||
*/
|
||||
setStore: (
|
||||
updateStoreFn: (prevState: Immutable<Partial<TStore>>) => MaybeImmutable<TStore>
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* The status of the command execution.
|
||||
* Note that the console's UI will show the command as "busy" while the status here is
|
||||
|
@ -86,11 +95,18 @@ export type CommandExecutionComponent = ComponentType<{
|
|||
* either `success` or `error`.
|
||||
*/
|
||||
status: CommandExecutionState['status'];
|
||||
|
||||
/** Set the status of the command execution */
|
||||
setStatus: (status: CommandExecutionState['status']) => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type CommandExecutionComponentProps = ComponentProps<CommandExecutionComponent>;
|
||||
/**
|
||||
* The component that will handle the Command execution and display the result.
|
||||
*/
|
||||
export type CommandExecutionComponent<
|
||||
TStore extends object = Record<string, unknown>,
|
||||
TMeta = any
|
||||
> = ComponentType<CommandExecutionComponentProps<TStore, TMeta>>;
|
||||
|
||||
export interface ConsoleProps extends CommonProps {
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import {
|
||||
EndpointAgentAndIsolationStatus,
|
||||
EndpointAgentAndIsolationStatusProps,
|
||||
} from './endpoint_agent_and_isolation_status';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
import React from 'react';
|
||||
|
||||
describe('When using the EndpointAgentAndIsolationStatus component', () => {
|
||||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
let renderProps: EndpointAgentAndIsolationStatusProps;
|
||||
|
||||
beforeEach(() => {
|
||||
const appTestContext = createAppRootMockRenderer();
|
||||
|
||||
renderProps = {
|
||||
status: HostStatus.HEALTHY,
|
||||
'data-test-subj': 'test',
|
||||
};
|
||||
|
||||
render = () => {
|
||||
renderResult = appTestContext.render(<EndpointAgentAndIsolationStatus {...renderProps} />);
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
it('should display host status only when `isIsolated` is undefined', () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.queryByTestId('test-isolationStatus')).toBeNull();
|
||||
});
|
||||
|
||||
it('should display pending status and pending counts', () => {
|
||||
renderProps.isIsolated = true;
|
||||
render();
|
||||
|
||||
expect(renderResult.getByTestId('test-isolationStatus')).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { useTestIdGenerator } from '../hooks/use_test_id_generator';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
import { AgentStatus } from '../../../common/components/endpoint/agent_status';
|
||||
import {
|
||||
EndpointHostIsolationStatus,
|
||||
EndpointHostIsolationStatusProps,
|
||||
} from '../../../common/components/endpoint/host_isolation';
|
||||
|
||||
const EuiFlexGroupStyled = styled(EuiFlexGroup)`
|
||||
.isolation-status {
|
||||
margin-left: ${({ theme }) => theme.eui.paddingSizes.s};
|
||||
}
|
||||
`;
|
||||
|
||||
export interface EndpointAgentAndIsolationStatusProps
|
||||
extends Pick<EndpointHostIsolationStatusProps, 'pendingIsolate' | 'pendingUnIsolate'> {
|
||||
status: HostStatus;
|
||||
/**
|
||||
* If defined with a boolean, then the isolation status will be shown along with the agent status.
|
||||
* The `pendingIsolate` and `pendingUnIsolate` props will only be used when this prop is set to a
|
||||
* `boolean`
|
||||
*/
|
||||
isIsolated?: boolean;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
export const EndpointAgentAndIsolationStatus = memo<EndpointAgentAndIsolationStatusProps>(
|
||||
({ status, isIsolated, pendingIsolate, pendingUnIsolate, 'data-test-subj': dataTestSubj }) => {
|
||||
const getTestId = useTestIdGenerator(dataTestSubj);
|
||||
return (
|
||||
<EuiFlexGroupStyled
|
||||
gutterSize="none"
|
||||
responsive={false}
|
||||
className="eui-textTruncate"
|
||||
data-test-subj={dataTestSubj}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AgentStatus hostStatus={status} />
|
||||
</EuiFlexItem>
|
||||
{isIsolated !== undefined && (
|
||||
<EuiFlexItem grow={false} className="eui-textTruncate isolation-status">
|
||||
<EndpointHostIsolationStatus
|
||||
data-test-subj={getTestId('isolationStatus')}
|
||||
isIsolated={isIsolated}
|
||||
pendingIsolate={pendingIsolate}
|
||||
pendingUnIsolate={pendingUnIsolate}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroupStyled>
|
||||
);
|
||||
}
|
||||
);
|
||||
EndpointAgentAndIsolationStatus.displayName = 'EndpointAgentAndIsolationStatus';
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { EndpointAgentAndIsolationStatus } from './endpoint_agent_and_isolation_status';
|
||||
export type { EndpointAgentAndIsolationStatusProps } from './endpoint_agent_and_isolation_status';
|
|
@ -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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import {
|
||||
EndpointAppliedPolicyStatus,
|
||||
EndpointAppliedPolicyStatusProps,
|
||||
} from './endpoint_applied_policy_status';
|
||||
import React from 'react';
|
||||
import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data';
|
||||
import { POLICY_STATUS_TO_TEXT } from '../../pages/endpoint_hosts/view/host_constants';
|
||||
|
||||
describe('when using EndpointPolicyStatus component', () => {
|
||||
let render: () => ReturnType<AppContextTestRender['render']>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
let renderProps: EndpointAppliedPolicyStatusProps;
|
||||
|
||||
beforeEach(() => {
|
||||
const appTestContext = createAppRootMockRenderer();
|
||||
|
||||
renderProps = {
|
||||
policyApplied: new EndpointDocGenerator('seed').generateHostMetadata().Endpoint.policy
|
||||
.applied,
|
||||
};
|
||||
|
||||
render = () => {
|
||||
renderResult = appTestContext.render(<EndpointAppliedPolicyStatus {...renderProps} />);
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
it('should display status from metadata `policy.applied` value', () => {
|
||||
render();
|
||||
|
||||
expect(renderResult.getByTestId('policyStatus').textContent).toEqual(
|
||||
POLICY_STATUS_TO_TEXT[renderProps.policyApplied.status]
|
||||
);
|
||||
});
|
||||
|
||||
it('should display status passed as `children`', () => {
|
||||
renderProps.children = 'status goes here';
|
||||
render();
|
||||
|
||||
expect(renderResult.getByTestId('policyStatus').textContent).toEqual('status goes here');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, PropsWithChildren } from 'react';
|
||||
import { EuiHealth, EuiToolTip, EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import {
|
||||
POLICY_STATUS_TO_HEALTH_COLOR,
|
||||
POLICY_STATUS_TO_TEXT,
|
||||
} from '../../pages/endpoint_hosts/view/host_constants';
|
||||
import { HostMetadata } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Displays the status of an applied policy on the Endpoint (using the information provided
|
||||
* by the endpoint in the Metadata document `Endpoint.policy.applied`.
|
||||
* By default, the policy status is displayed as plain text, however, that can be overridden
|
||||
* by defining the `children` prop or passing a child component to this one.
|
||||
*/
|
||||
export type EndpointAppliedPolicyStatusProps = PropsWithChildren<{
|
||||
policyApplied: HostMetadata['Endpoint']['policy']['applied'];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Display the status of the Policy applied on an endpoint
|
||||
*/
|
||||
export const EndpointAppliedPolicyStatus = memo<EndpointAppliedPolicyStatusProps>(
|
||||
({ policyApplied, children }) => {
|
||||
return (
|
||||
<EuiToolTip
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointPolicyStatus.tooltipTitleLabel"
|
||||
defaultMessage="Policy applied"
|
||||
/>
|
||||
}
|
||||
anchorClassName="eui-textTruncate"
|
||||
content={
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
gutterSize="s"
|
||||
alignItems="center"
|
||||
data-test-subj="endpointAppliedPolicyTooltipInfo"
|
||||
>
|
||||
<EuiFlexItem className="eui-textTruncate" grow>
|
||||
<EuiText size="s" className="eui-textTruncate">
|
||||
{policyApplied.name}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
{policyApplied.endpoint_policy_version && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText
|
||||
color="subdued"
|
||||
size="xs"
|
||||
style={{ whiteSpace: 'nowrap', paddingLeft: '6px' }}
|
||||
className="eui-textTruncate"
|
||||
data-test-subj="policyRevision"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointPolicyStatus.revisionNumber"
|
||||
defaultMessage="rev. {revNumber}"
|
||||
values={{ revNumber: policyApplied.endpoint_policy_version }}
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
>
|
||||
<EuiHealth
|
||||
color={POLICY_STATUS_TO_HEALTH_COLOR[policyApplied.status]}
|
||||
className="eui-textTruncate eui-fullWidth"
|
||||
data-test-subj="policyStatus"
|
||||
>
|
||||
{children !== undefined ? children : POLICY_STATUS_TO_TEXT[policyApplied.status]}
|
||||
</EuiHealth>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
);
|
||||
EndpointAppliedPolicyStatus.displayName = 'EndpointAppliedPolicyStatus';
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { EndpointAppliedPolicyStatus } from './endpoint_applied_policy_status';
|
||||
export type { EndpointAppliedPolicyStatusProps } from './endpoint_applied_policy_status';
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
import { CommandDefinition } from '../console';
|
||||
import { IsolateActionResult } from './isolate_action';
|
||||
import { EndpointStatusActionResult } from './status_action';
|
||||
|
||||
export const getEndpointResponseActionsConsoleCommands = (
|
||||
endpointAgentId: string
|
||||
): CommandDefinition[] => {
|
||||
return [
|
||||
{
|
||||
name: 'isolate',
|
||||
about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.isolate.about', {
|
||||
defaultMessage: 'Isolate the host',
|
||||
}),
|
||||
RenderComponent: IsolateActionResult,
|
||||
meta: {
|
||||
endpointId: endpointAgentId,
|
||||
},
|
||||
args: {
|
||||
comment: {
|
||||
required: false,
|
||||
allowMultiples: false,
|
||||
about: i18n.translate(
|
||||
'xpack.securitySolution.endpointConsoleCommands.isolate.arg.command',
|
||||
{ defaultMessage: 'A comment to go along with the action' }
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
about: i18n.translate('xpack.securitySolution.endpointConsoleCommands.status.about', {
|
||||
defaultMessage: 'Display the latest status information for the Endpoint',
|
||||
}),
|
||||
RenderComponent: EndpointStatusActionResult,
|
||||
meta: {
|
||||
endpointId: endpointAgentId,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';
|
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
* 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 { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint';
|
||||
import {
|
||||
ConsoleManagerTestComponent,
|
||||
getConsoleManagerMockRenderResultQueriesAndActions,
|
||||
} from '../console/components/console_manager/mocks';
|
||||
import React from 'react';
|
||||
import { getEndpointResponseActionsConsoleCommands } from './endpoint_response_actions_console_commands';
|
||||
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
|
||||
import { enterConsoleCommand } from '../console/mocks';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
describe('When using isolate action from response actions console', () => {
|
||||
let render: () => Promise<ReturnType<AppContextTestRender['render']>>;
|
||||
let renderResult: ReturnType<AppContextTestRender['render']>;
|
||||
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
|
||||
let consoleManagerMockAccess: ReturnType<
|
||||
typeof getConsoleManagerMockRenderResultQueriesAndActions
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockedContext = createAppRootMockRenderer();
|
||||
|
||||
apiMocks = responseActionsHttpMocks(mockedContext.coreStart.http);
|
||||
|
||||
render = async () => {
|
||||
renderResult = mockedContext.render(
|
||||
<ConsoleManagerTestComponent
|
||||
registerConsoleProps={() => {
|
||||
return {
|
||||
consoleProps: {
|
||||
'data-test-subj': 'test',
|
||||
commands: getEndpointResponseActionsConsoleCommands('a.b.c'),
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
consoleManagerMockAccess = getConsoleManagerMockRenderResultQueriesAndActions(renderResult);
|
||||
|
||||
await consoleManagerMockAccess.clickOnRegisterNewConsole();
|
||||
await consoleManagerMockAccess.openRunningConsole();
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
});
|
||||
|
||||
it('should call `isolate` api when command is entered', async () => {
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept an optional `--comment`', async () => {
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate --comment "This is a comment"');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining('This is a comment'),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should only accept one `--comment`', async () => {
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate --comment "one" --comment "two"');
|
||||
|
||||
expect(renderResult.getByTestId('test-badArgument').textContent).toMatch(
|
||||
/argument can only be used once: --comment/
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the action status api after creating the `isolate` request', async () => {
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show success when `isolate` action completes with no errors', async () => {
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderResult.getByTestId('isolateSuccessCallout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error if isolate failed to complete successfully', async () => {
|
||||
const pendingDetailResponse = apiMocks.responseProvider.actionDetails({
|
||||
path: '/api/endpoint/action/1.2.3',
|
||||
});
|
||||
pendingDetailResponse.data.wasSuccessful = false;
|
||||
pendingDetailResponse.data.errors = ['error one', 'error two'];
|
||||
apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse);
|
||||
await render();
|
||||
enterConsoleCommand(renderResult, 'isolate');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderResult.getByTestId('isolateErrorCallout').textContent).toMatch(
|
||||
/error one \| error two/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and when console is closed (not terminated) and then reopened', () => {
|
||||
beforeEach(() => {
|
||||
const _render = render;
|
||||
|
||||
render = async () => {
|
||||
const response = await _render();
|
||||
enterConsoleCommand(response, 'isolate');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Hide the console
|
||||
await consoleManagerMockAccess.hideOpenedConsole();
|
||||
|
||||
return response;
|
||||
};
|
||||
});
|
||||
|
||||
it('should NOT send the `isolate` request again', async () => {
|
||||
await render();
|
||||
await consoleManagerMockAccess.openRunningConsole();
|
||||
|
||||
expect(apiMocks.responseProvider.isolateHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should continue to check action status when still pending', async () => {
|
||||
const pendingDetailResponse = apiMocks.responseProvider.actionDetails({
|
||||
path: '/api/endpoint/action/1.2.3',
|
||||
});
|
||||
pendingDetailResponse.data.isCompleted = false;
|
||||
apiMocks.responseProvider.actionDetails.mockReturnValue(pendingDetailResponse);
|
||||
await render();
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(2);
|
||||
|
||||
await consoleManagerMockAccess.hideOpenedConsole();
|
||||
await consoleManagerMockAccess.openRunningConsole();
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should display completion output if done (no additional API calls)', async () => {
|
||||
await render();
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
|
||||
|
||||
await consoleManagerMockAccess.hideOpenedConsole();
|
||||
await consoleManagerMockAccess.openRunningConsole();
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* 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, useEffect } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiCallOut } from '@elastic/eui';
|
||||
import { ActionDetails } from '../../../../common/endpoint/types';
|
||||
import { useGetActionDetails } from '../../hooks/endpoint/use_get_action_details';
|
||||
import { EndpointCommandDefinitionMeta } from './types';
|
||||
import { useSendIsolateEndpointRequest } from '../../hooks/endpoint/use_send_isolate_endpoint_request';
|
||||
import { CommandExecutionComponentProps } from '../console/types';
|
||||
|
||||
export const IsolateActionResult = memo<
|
||||
CommandExecutionComponentProps<
|
||||
{
|
||||
actionId?: string;
|
||||
actionRequestSent?: boolean;
|
||||
completedActionDetails?: ActionDetails;
|
||||
},
|
||||
EndpointCommandDefinitionMeta
|
||||
>
|
||||
>(({ command, setStore, store, status, setStatus }) => {
|
||||
const endpointId = command.commandDefinition?.meta?.endpointId;
|
||||
const { actionId, completedActionDetails } = store;
|
||||
const isPending = status === 'pending';
|
||||
const actionRequestSent = Boolean(store.actionRequestSent);
|
||||
|
||||
const isolateHostApi = useSendIsolateEndpointRequest();
|
||||
|
||||
const { data: actionDetails } = useGetActionDetails(actionId ?? '-', {
|
||||
enabled: Boolean(actionId) && isPending,
|
||||
refetchInterval: isPending ? 3000 : false,
|
||||
});
|
||||
|
||||
// Send Isolate request if not yet done
|
||||
useEffect(() => {
|
||||
if (!actionRequestSent && endpointId) {
|
||||
isolateHostApi.mutate({
|
||||
endpoint_ids: [endpointId],
|
||||
comment: command.args.args?.comment?.value,
|
||||
});
|
||||
|
||||
setStore((prevState) => {
|
||||
return { ...prevState, actionRequestSent: true };
|
||||
});
|
||||
}
|
||||
}, [actionRequestSent, command.args.args?.comment?.value, endpointId, isolateHostApi, setStore]);
|
||||
|
||||
// If isolate request was created, store the action id if necessary
|
||||
useEffect(() => {
|
||||
if (isolateHostApi.isSuccess && actionId !== isolateHostApi.data.action) {
|
||||
setStore((prevState) => {
|
||||
return { ...prevState, actionId: isolateHostApi.data.action };
|
||||
});
|
||||
}
|
||||
}, [actionId, isolateHostApi?.data?.action, isolateHostApi.isSuccess, setStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionDetails?.data.isCompleted) {
|
||||
setStatus('success');
|
||||
setStore((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
completedActionDetails: actionDetails.data,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [actionDetails?.data, setStatus, setStore]);
|
||||
|
||||
// Show nothing if still pending
|
||||
if (isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show errors
|
||||
if (completedActionDetails?.errors) {
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
title={i18n.translate(
|
||||
'xpack.securitySolution.endpointResponseActions.isolate.errorMessageTitle',
|
||||
{ defaultMessage: 'Failure' }
|
||||
)}
|
||||
data-test-subj="isolateErrorCallout"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointResponseActions.isolate.errorMessage"
|
||||
defaultMessage="Isolate action failed with: {errors}"
|
||||
values={{ errors: completedActionDetails.errors.join(' | ') }}
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
// Show Success
|
||||
return (
|
||||
<EuiCallOut
|
||||
color="success"
|
||||
iconType="check"
|
||||
title={i18n.translate(
|
||||
'xpack.securitySolution.endpointResponseActions.isolate.successMessageTitle',
|
||||
{ defaultMessage: 'Success' }
|
||||
)}
|
||||
data-test-subj="isolateSuccessCallout"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointResponseActions.isolate.successMessage"
|
||||
defaultMessage="A host isolation request was sent and an acknowledgement was received from Host."
|
||||
/>
|
||||
</EuiCallOut>
|
||||
);
|
||||
});
|
||||
IsolateActionResult.displayName = 'IsolateActionResult';
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* 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, useEffect, useMemo } from 'react';
|
||||
import { EuiCallOut, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { HttpFetchError } from '@kbn/core/public';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
import type { HostInfo, PendingActionsResponse } from '../../../../common/endpoint/types';
|
||||
import type { EndpointCommandDefinitionMeta } from './types';
|
||||
import { EndpointHostIsolationStatusProps } from '../../../common/components/endpoint/host_isolation';
|
||||
import { useGetEndpointPendingActionsSummary } from '../../hooks/endpoint/use_get_endpoint_pending_actions_summary';
|
||||
import { FormattedDate } from '../../../common/components/formatted_date';
|
||||
import { EndpointAppliedPolicyStatus } from '../endpoint_applied_policy_status';
|
||||
import { EndpointAgentAndIsolationStatus } from '../endpoint_agent_and_isolation_status';
|
||||
import { useGetEndpointDetails } from '../../hooks';
|
||||
import type { CommandExecutionComponentProps } from '../console/types';
|
||||
import { FormattedError } from '../formatted_error';
|
||||
|
||||
export const EndpointStatusActionResult = memo<
|
||||
CommandExecutionComponentProps<
|
||||
{
|
||||
apiCalled?: boolean;
|
||||
endpointDetails?: HostInfo;
|
||||
detailsFetchError?: HttpFetchError;
|
||||
endpointPendingActions?: PendingActionsResponse;
|
||||
},
|
||||
EndpointCommandDefinitionMeta
|
||||
>
|
||||
>(({ command, status, setStatus, store, setStore }) => {
|
||||
const endpointId = command.commandDefinition?.meta?.endpointId as string;
|
||||
const { endpointPendingActions, endpointDetails, detailsFetchError, apiCalled } = store;
|
||||
const isPending = status === 'pending';
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isFetched,
|
||||
refetch: fetchEndpointDetails,
|
||||
} = useGetEndpointDetails(endpointId, { enabled: false });
|
||||
|
||||
const { refetch: fetchEndpointPendingActionsSummary } = useGetEndpointPendingActionsSummary(
|
||||
[endpointId],
|
||||
{ enabled: false }
|
||||
);
|
||||
|
||||
const pendingIsolationActions = useMemo<
|
||||
Pick<Required<EndpointHostIsolationStatusProps>, 'pendingIsolate' | 'pendingUnIsolate'>
|
||||
>(() => {
|
||||
if (endpointPendingActions?.data.length) {
|
||||
const pendingActions = endpointPendingActions.data[0].pending_actions;
|
||||
|
||||
return {
|
||||
pendingIsolate: pendingActions.isolate ?? 0,
|
||||
pendingUnIsolate: pendingActions.unisolate ?? 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
pendingIsolate: 0,
|
||||
pendingUnIsolate: 0,
|
||||
};
|
||||
}, [endpointPendingActions?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiCalled) {
|
||||
setStore((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
apiCalled: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Using a unique `queryKey` here and below so that data is NOT updated
|
||||
// from cache when future requests for this endpoint ID is done again.
|
||||
fetchEndpointDetails({ queryKey: uuidV4() })
|
||||
.then(({ data }) => {
|
||||
setStore((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
endpointDetails: data,
|
||||
};
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
setStore((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
detailsFetchError: err,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
fetchEndpointPendingActionsSummary({ queryKey: uuidV4() }).then(({ data }) => {
|
||||
setStore((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
endpointPendingActions: data,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [apiCalled, fetchEndpointDetails, fetchEndpointPendingActionsSummary, setStore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetched && isPending) {
|
||||
setStatus(detailsFetchError ? 'error' : 'success');
|
||||
}
|
||||
}, [detailsFetchError, isFetched, setStatus, isPending]);
|
||||
|
||||
if (isFetching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (detailsFetchError) {
|
||||
return (
|
||||
<EuiCallOut>
|
||||
<EuiFieldText>
|
||||
<FormattedError error={detailsFetchError} />
|
||||
</EuiFieldText>
|
||||
</EuiCallOut>
|
||||
);
|
||||
}
|
||||
|
||||
if (!endpointDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup wrap={false} responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointResponseActions.status.agentStatus"
|
||||
defaultMessage="Agent status"
|
||||
/>
|
||||
</EuiText>
|
||||
<EndpointAgentAndIsolationStatus
|
||||
status={endpointDetails.host_status}
|
||||
isIsolated={Boolean(endpointDetails.metadata.Endpoint.state?.isolation)}
|
||||
{...pendingIsolationActions}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointResponseActions.status.policyStatus"
|
||||
defaultMessage="Policy status"
|
||||
/>
|
||||
</EuiText>
|
||||
<EndpointAppliedPolicyStatus
|
||||
policyApplied={endpointDetails.metadata.Endpoint.policy.applied}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpointResponseActions.status.lastActive"
|
||||
defaultMessage="Last active"
|
||||
/>
|
||||
</EuiText>
|
||||
<EuiText>
|
||||
<FormattedDate
|
||||
fieldName={i18n.translate(
|
||||
'xpack.securitySolution.endpointResponseActions.status.lastActive',
|
||||
{ defaultMessage: 'Last active' }
|
||||
)}
|
||||
value={endpointDetails.metadata['@timestamp']}
|
||||
className="eui-textTruncate"
|
||||
/>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
EndpointStatusActionResult.displayName = 'EndpointStatusActionResult';
|
|
@ -5,4 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ConsolesPopoverHeaderSectionItem } from './consoles_popover_header_section_item';
|
||||
export interface EndpointCommandDefinitionMeta {
|
||||
endpointId: string;
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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 {
|
||||
AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
ReactQueryHookRenderer,
|
||||
} from '../../../common/mock/endpoint';
|
||||
import { useGetActionDetails } from './use_get_action_details';
|
||||
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
|
||||
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
|
||||
import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { useQuery as _useQuery } from 'react-query';
|
||||
|
||||
const useQueryMock = _useQuery as jest.Mock;
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actualReactQueryModule = jest.requireActual('react-query');
|
||||
|
||||
return {
|
||||
...actualReactQueryModule,
|
||||
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useGetActionDetails hook', () => {
|
||||
let renderReactQueryHook: ReactQueryHookRenderer<
|
||||
Parameters<typeof useGetActionDetails>,
|
||||
ReturnType<typeof useGetActionDetails>
|
||||
>;
|
||||
let http: AppContextTestRender['coreStart']['http'];
|
||||
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testContext = createAppRootMockRenderer();
|
||||
|
||||
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
|
||||
http = testContext.coreStart.http;
|
||||
|
||||
apiMocks = responseActionsHttpMocks(http);
|
||||
});
|
||||
|
||||
it('should call the proper API', async () => {
|
||||
await renderReactQueryHook(() => useGetActionDetails('123'));
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({
|
||||
path: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: '123' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call api with `undefined` for action id if it was not defined on input', async () => {
|
||||
await renderReactQueryHook(() => useGetActionDetails(''));
|
||||
|
||||
expect(apiMocks.responseProvider.actionDetails).toHaveBeenCalledWith({
|
||||
path: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: 'undefined' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow custom options to be used', async () => {
|
||||
await renderReactQueryHook(
|
||||
() => useGetActionDetails('123', { queryKey: ['1', '2'], enabled: false }),
|
||||
false
|
||||
);
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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 { UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import type { HttpFetchError } from '@kbn/core/public';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useHttp } from '../../../common/lib/kibana';
|
||||
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
|
||||
import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import type { ActionDetailsApiResponse } from '../../../../common/endpoint/types';
|
||||
|
||||
export const useGetActionDetails = (
|
||||
actionId: string,
|
||||
options: UseQueryOptions<ActionDetailsApiResponse, HttpFetchError> = {}
|
||||
): UseQueryResult<ActionDetailsApiResponse, HttpFetchError> => {
|
||||
const http = useHttp();
|
||||
|
||||
return useQuery<ActionDetailsApiResponse, HttpFetchError>({
|
||||
queryKey: ['get-action-details', actionId],
|
||||
...options,
|
||||
queryFn: () => {
|
||||
return http.get<ActionDetailsApiResponse>(
|
||||
resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: actionId.trim() || 'undefined' })
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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 { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import type { HttpFetchError } from '@kbn/core/public';
|
||||
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
|
||||
import { useHttp } from '../../../common/lib/kibana';
|
||||
import type { HostInfo } from '../../../../common/endpoint/types';
|
||||
import { HOST_METADATA_GET_ROUTE } from '../../../../common/endpoint/constants';
|
||||
|
||||
/**
|
||||
* Get info for a security solution endpoint host using the endpoint id (`agent.id`)
|
||||
* @param endpointId
|
||||
* @param options
|
||||
*/
|
||||
export const useGetEndpointDetails = (
|
||||
endpointId: string,
|
||||
options: UseQueryOptions<HostInfo, HttpFetchError> = {}
|
||||
): UseQueryResult<HostInfo, HttpFetchError> => {
|
||||
const http = useHttp();
|
||||
|
||||
return useQuery<HostInfo, HttpFetchError>({
|
||||
queryKey: ['get-endpoint-host-info', endpointId],
|
||||
...options,
|
||||
queryFn: () => {
|
||||
return http.get<HostInfo>(
|
||||
resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: endpointId.trim() || 'undefined' })
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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 {
|
||||
AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
ReactQueryHookRenderer,
|
||||
} from '../../../common/mock/endpoint';
|
||||
import { useGetEndpointDetails } from './use_get_endpoint_details';
|
||||
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
|
||||
import { HOST_METADATA_GET_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { useQuery as _useQuery } from 'react-query';
|
||||
import { endpointMetadataHttpMocks } from '../../pages/endpoint_hosts/mocks';
|
||||
|
||||
const useQueryMock = _useQuery as jest.Mock;
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actualReactQueryModule = jest.requireActual('react-query');
|
||||
|
||||
return {
|
||||
...actualReactQueryModule,
|
||||
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useGetEndpointDetails hook', () => {
|
||||
let renderReactQueryHook: ReactQueryHookRenderer<
|
||||
Parameters<typeof useGetEndpointDetails>,
|
||||
ReturnType<typeof useGetEndpointDetails>
|
||||
>;
|
||||
let http: AppContextTestRender['coreStart']['http'];
|
||||
let apiMocks: ReturnType<typeof endpointMetadataHttpMocks>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testContext = createAppRootMockRenderer();
|
||||
|
||||
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
|
||||
http = testContext.coreStart.http;
|
||||
|
||||
apiMocks = endpointMetadataHttpMocks(http);
|
||||
});
|
||||
|
||||
it('should call the proper API', async () => {
|
||||
await renderReactQueryHook(() => useGetEndpointDetails('123'));
|
||||
|
||||
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalledWith({
|
||||
path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: '123' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call api with `undefined` for endpoint id if it was not defined on input', async () => {
|
||||
await renderReactQueryHook(() => useGetEndpointDetails(''));
|
||||
|
||||
expect(apiMocks.responseProvider.metadataDetails).toHaveBeenCalledWith({
|
||||
path: resolvePathVariables(HOST_METADATA_GET_ROUTE, { id: 'undefined' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow custom options to be used', async () => {
|
||||
await renderReactQueryHook(
|
||||
() => useGetEndpointDetails('123', { queryKey: ['1', '2'], enabled: false }),
|
||||
false
|
||||
);
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* 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 {
|
||||
AppContextTestRender,
|
||||
createAppRootMockRenderer,
|
||||
ReactQueryHookRenderer,
|
||||
} from '../../../common/mock/endpoint';
|
||||
import { useGetEndpointPendingActionsSummary } from './use_get_endpoint_pending_actions_summary';
|
||||
import { ACTION_STATUS_ROUTE } from '../../../../common/endpoint/constants';
|
||||
import { useQuery as _useQuery } from 'react-query';
|
||||
import { responseActionsHttpMocks } from '../../pages/mocks/response_actions_http_mocks';
|
||||
|
||||
const useQueryMock = _useQuery as jest.Mock;
|
||||
|
||||
jest.mock('react-query', () => {
|
||||
const actualReactQueryModule = jest.requireActual('react-query');
|
||||
|
||||
return {
|
||||
...actualReactQueryModule,
|
||||
useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)),
|
||||
};
|
||||
});
|
||||
|
||||
describe('useGetEndpointPendingActionsSummary hook', () => {
|
||||
let renderReactQueryHook: ReactQueryHookRenderer<
|
||||
Parameters<typeof useGetEndpointPendingActionsSummary>,
|
||||
ReturnType<typeof useGetEndpointPendingActionsSummary>
|
||||
>;
|
||||
let http: AppContextTestRender['coreStart']['http'];
|
||||
let apiMocks: ReturnType<typeof responseActionsHttpMocks>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testContext = createAppRootMockRenderer();
|
||||
|
||||
renderReactQueryHook = testContext.renderReactQueryHook as typeof renderReactQueryHook;
|
||||
http = testContext.coreStart.http;
|
||||
|
||||
apiMocks = responseActionsHttpMocks(http);
|
||||
});
|
||||
|
||||
it('should call the proper API', async () => {
|
||||
await renderReactQueryHook(() => useGetEndpointPendingActionsSummary(['123', '456']));
|
||||
|
||||
expect(apiMocks.responseProvider.agentPendingActionsSummary).toHaveBeenCalledWith({
|
||||
path: `${ACTION_STATUS_ROUTE}`,
|
||||
query: { agent_ids: ['123', '456'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow custom options to be used', async () => {
|
||||
await renderReactQueryHook(
|
||||
() =>
|
||||
useGetEndpointPendingActionsSummary(['123', '456'], {
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
expect(useQueryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
queryKey: ['1', '2'],
|
||||
enabled: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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 { QueryObserverResult, UseQueryOptions, useQuery } from 'react-query';
|
||||
import { HttpFetchError } from '@kbn/core/public';
|
||||
import { PendingActionsResponse } from '../../../../common/endpoint/types';
|
||||
import { fetchPendingActionsByAgentId } from '../../../common/lib/endpoint_pending_actions';
|
||||
|
||||
/**
|
||||
* Retrieves the pending actions against the given Endpoint `agent.id`'s
|
||||
* @param endpointAgentIds
|
||||
* @param options
|
||||
*/
|
||||
export const useGetEndpointPendingActionsSummary = (
|
||||
endpointAgentIds: string[],
|
||||
options: UseQueryOptions<PendingActionsResponse, HttpFetchError> = {}
|
||||
): QueryObserverResult<PendingActionsResponse, HttpFetchError> => {
|
||||
return useQuery<PendingActionsResponse, HttpFetchError>({
|
||||
queryKey: ['fetch-endpoint-pending-actions-summary', ...endpointAgentIds],
|
||||
...options,
|
||||
queryFn: () => fetchPendingActionsByAgentId(endpointAgentIds),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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 { useMutation, UseMutationOptions } from 'react-query';
|
||||
import { HttpFetchError } from '@kbn/core/public';
|
||||
import { isolateHost } from '../../../common/lib/endpoint_isolation';
|
||||
import { HostIsolationRequestBody, HostIsolationResponse } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Create host isolation requests
|
||||
* @param customOptions
|
||||
*/
|
||||
export const useSendIsolateEndpointRequest = (
|
||||
customOptions?: UseMutationOptions<
|
||||
HostIsolationResponse,
|
||||
HttpFetchError,
|
||||
HostIsolationRequestBody
|
||||
>
|
||||
) => {
|
||||
return useMutation<HostIsolationResponse, HttpFetchError, HostIsolationRequestBody>(
|
||||
(isolateData: HostIsolationRequestBody) => {
|
||||
return isolateHost(isolateData);
|
||||
},
|
||||
customOptions
|
||||
);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
import { getEndpointResponseActionsConsoleCommands } from '../../components/endpoint_console/endpoint_response_actions_console_commands';
|
||||
import { useConsoleManager } from '../../components/console';
|
||||
import type { HostMetadata } from '../../../../common/endpoint/types';
|
||||
|
||||
type ShowEndpointResponseActionsConsole = (endpointMetadata: HostMetadata) => void;
|
||||
|
||||
export const useShowEndpointResponseActionsConsole = (): ShowEndpointResponseActionsConsole => {
|
||||
const consoleManager = useConsoleManager();
|
||||
|
||||
return useCallback(
|
||||
(endpointMetadata: HostMetadata) => {
|
||||
const endpointAgentId = endpointMetadata.agent.id;
|
||||
const endpointRunningConsole = consoleManager.getOne(endpointAgentId);
|
||||
|
||||
if (endpointRunningConsole) {
|
||||
endpointRunningConsole.show();
|
||||
} else {
|
||||
consoleManager
|
||||
.register({
|
||||
id: endpointAgentId,
|
||||
title: `${endpointMetadata.host.name} - Endpoint v${endpointMetadata.agent.version}`,
|
||||
consoleProps: {
|
||||
commands: getEndpointResponseActionsConsoleCommands(endpointAgentId),
|
||||
'data-test-subj': 'endpointResponseActionsConsole',
|
||||
prompt: `endpoint-${endpointMetadata.agent.version}`,
|
||||
},
|
||||
})
|
||||
.show();
|
||||
}
|
||||
},
|
||||
[consoleManager]
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { useGetEndpointDetails } from './endpoint/use_get_endpoint_details';
|
||||
export { useShowEndpointResponseActionsConsole } from './endpoint/use_show_endpoint_response_actions_console';
|
|
@ -31,7 +31,17 @@ export const TableRowActions = memo<TableRowActionProps>(({ endpointMetadata })
|
|||
|
||||
const menuItems: EuiContextMenuPanelProps['items'] = useMemo(() => {
|
||||
return endpointActions.map((itemProps) => {
|
||||
return <ContextMenuItemNavByRouter {...itemProps} onClick={handleCloseMenu} />;
|
||||
return (
|
||||
<ContextMenuItemNavByRouter
|
||||
{...itemProps}
|
||||
onClick={(ev) => {
|
||||
handleCloseMenu();
|
||||
if (itemProps.onClick) {
|
||||
itemProps.onClick(ev);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [handleCloseMenu, endpointActions]);
|
||||
|
||||
|
|
|
@ -23,7 +23,17 @@ export const ActionsMenu = React.memo<{}>(() => {
|
|||
|
||||
const takeActionItems = useMemo(() => {
|
||||
return menuOptions.map((item) => {
|
||||
return <ContextMenuItemNavByRouter {...item} onClick={closePopoverHandler} />;
|
||||
return (
|
||||
<ContextMenuItemNavByRouter
|
||||
{...item}
|
||||
onClick={(ev) => {
|
||||
closePopoverHandler();
|
||||
if (item.onClick) {
|
||||
item.onClick(ev);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [closePopoverHandler, menuOptions]);
|
||||
|
||||
|
|
|
@ -1,268 +0,0 @@
|
|||
/*
|
||||
* 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,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiCode,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { useIsMounted } from '../../../components/hooks/use_is_mounted';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useUrlParams } from '../../../components/hooks/use_url_params';
|
||||
import {
|
||||
CommandDefinition,
|
||||
Console,
|
||||
RegisteredConsoleClient,
|
||||
useConsoleManager,
|
||||
} from '../../../components/console';
|
||||
|
||||
const delay = async (ms: number = 4000) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const getCommandList = (): CommandDefinition[] => {
|
||||
return [
|
||||
{
|
||||
name: 'cmd1',
|
||||
about: 'Runs cmd1',
|
||||
RenderComponent: ({ command, setStatus, store, setStore }) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [apiResponse, setApiResponse] = useState<null | string>(null);
|
||||
const [uiResponse, setUiResponse] = useState<null | ReactElement>(null);
|
||||
|
||||
// Emulate a real action where:
|
||||
// 1. an api request is done to create the action
|
||||
// 2. wait for a response
|
||||
// 3. account for component mount/unmount and prevent duplicate api calls
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// Emulate an api call
|
||||
if (!store.apiInflight) {
|
||||
setStore({
|
||||
...store,
|
||||
apiInflight: true,
|
||||
});
|
||||
|
||||
window.console.warn(`${Math.random()} ------> cmd1: doing async work`);
|
||||
|
||||
await delay(6000);
|
||||
setApiResponse(`API was called at: ${new Date().toLocaleString()}`);
|
||||
}
|
||||
})();
|
||||
}, [setStore, store]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const doUiResponse = () => {
|
||||
setUiResponse(
|
||||
<EuiText>
|
||||
<EuiText>{`${command.commandDefinition.name}`}</EuiText>
|
||||
<EuiText>{`command input: ${command.input}`}</EuiText>
|
||||
<EuiText>{'Arguments provided:'}</EuiText>
|
||||
<EuiCode>{JSON.stringify(command.args, null, 2)}</EuiCode>
|
||||
</EuiText>
|
||||
);
|
||||
};
|
||||
|
||||
if (store.apiResponse) {
|
||||
doUiResponse();
|
||||
} else {
|
||||
await delay();
|
||||
doUiResponse();
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
command.args,
|
||||
command.commandDefinition.name,
|
||||
command.input,
|
||||
isMounted,
|
||||
store.apiResponse,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiResponse && uiResponse) {
|
||||
setStatus('success');
|
||||
}
|
||||
}, [apiResponse, setStatus, uiResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiResponse && store.apiResponse !== apiResponse) {
|
||||
setStore({
|
||||
...store,
|
||||
apiResponse,
|
||||
});
|
||||
}
|
||||
}, [apiResponse, setStore, store]);
|
||||
|
||||
if (store.apiResponse) {
|
||||
return (
|
||||
<div>
|
||||
{uiResponse}
|
||||
<EuiText>{store.apiResponse as ReactNode}</EuiText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
args: {
|
||||
one: {
|
||||
required: false,
|
||||
allowMultiples: false,
|
||||
about: 'just one',
|
||||
},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'get-file',
|
||||
// about: 'retrieve a file from the endpoint',
|
||||
// args: {
|
||||
// file: {
|
||||
// required: true,
|
||||
// allowMultiples: false,
|
||||
// about: 'the file path for the file to be retrieved',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'cmd2',
|
||||
// about: 'runs cmd 2',
|
||||
// args: {
|
||||
// file: {
|
||||
// required: true,
|
||||
// allowMultiples: false,
|
||||
// about: 'Includes file in the run',
|
||||
// validate: () => {
|
||||
// return true;
|
||||
// },
|
||||
// },
|
||||
// bad: {
|
||||
// required: false,
|
||||
// allowMultiples: false,
|
||||
// about: 'will fail validation',
|
||||
// validate: () => 'This is a bad value',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'cmd-long-delay',
|
||||
// about: 'runs cmd 2',
|
||||
// },
|
||||
];
|
||||
};
|
||||
|
||||
const RunningConsole = memo<{ registeredConsole: RegisteredConsoleClient }>(
|
||||
({ registeredConsole }) => {
|
||||
const handleShowOnClick = useCallback(() => {
|
||||
registeredConsole.show();
|
||||
}, [registeredConsole]);
|
||||
|
||||
const handleTerminateOnClick = useCallback(() => {
|
||||
registeredConsole.terminate();
|
||||
}, [registeredConsole]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow>{registeredConsole.title}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiButton onClick={handleTerminateOnClick} color="danger">
|
||||
{'terminate'}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiButton onClick={handleShowOnClick}>{'show'}</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
RunningConsole.displayName = 'RunningConsole';
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// FOR DEV PURPOSES ONLY
|
||||
// FIXME:PT Delete once we have support via row actions menu
|
||||
// ------------------------------------------------------------
|
||||
export const ShowDevConsole = memo(() => {
|
||||
const consoleManager = useConsoleManager();
|
||||
const commands = useMemo(() => {
|
||||
return getCommandList();
|
||||
}, []);
|
||||
|
||||
const handleRegisterOnClick = useCallback(() => {
|
||||
consoleManager
|
||||
.register({
|
||||
id: Math.random().toString(36), // getId(),
|
||||
title: 'Test console here',
|
||||
meta: {
|
||||
foo: 'bar',
|
||||
},
|
||||
consoleProps: {
|
||||
prompt: '>>',
|
||||
commands,
|
||||
'data-test-subj': 'dev',
|
||||
},
|
||||
})
|
||||
.show();
|
||||
}, [commands, consoleManager]);
|
||||
|
||||
return (
|
||||
<EuiPanel>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton onClick={handleRegisterOnClick}>{'Open a managed console'}</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow>
|
||||
{consoleManager.getList<{ foo: string }>().map((registeredConsole) => {
|
||||
return (
|
||||
<RunningConsole key={registeredConsole.id} registeredConsole={registeredConsole} />
|
||||
);
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
||||
<EuiSpacer size="xxl" />
|
||||
|
||||
<EuiText>
|
||||
<h3>{'Un-managed console'}</h3>
|
||||
</EuiText>
|
||||
<EuiPanel style={{ height: '600px' }}>
|
||||
<Console prompt="$$>" commands={getCommandList()} data-test-subj="dev" />
|
||||
</EuiPanel>
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
ShowDevConsole.displayName = 'ShowDevConsole';
|
||||
|
||||
export const DevConsole = memo(() => {
|
||||
const isConsoleEnabled = useIsExperimentalFeatureEnabled('responseActionsConsoleEnabled');
|
||||
const {
|
||||
urlParams: { showConsole = false },
|
||||
} = useUrlParams();
|
||||
|
||||
return isConsoleEnabled && showConsole ? <ShowDevConsole /> : null;
|
||||
});
|
||||
DevConsole.displayName = 'DevConsole';
|
|
@ -8,6 +8,8 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { pagePathGetters } from '@kbn/fleet-plugin/public';
|
||||
import { useShowEndpointResponseActionsConsole } from '../../../../hooks';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { APP_UI_ID } from '../../../../../../common/constants';
|
||||
import { getEndpointDetailsPath } from '../../../../common/routing';
|
||||
import { HostMetadata, MaybeImmutable } from '../../../../../../common/endpoint/types';
|
||||
|
@ -30,6 +32,10 @@ export const useEndpointActionItems = (
|
|||
const { getAppUrl } = useAppUrl();
|
||||
const fleetAgentPolicies = useEndpointSelector(agentPolicies);
|
||||
const allCurrentUrlParams = useEndpointSelector(uiQueryParams);
|
||||
const showEndpointResponseActionsConsole = useShowEndpointResponseActionsConsole();
|
||||
const isResponseActionsConsoleEnabled = useIsExperimentalFeatureEnabled(
|
||||
'responseActionsConsoleEnabled'
|
||||
);
|
||||
|
||||
return useMemo<ContextMenuItemNavByRouterProps[]>(() => {
|
||||
if (endpointMetadata) {
|
||||
|
@ -101,6 +107,25 @@ export const useEndpointActionItems = (
|
|||
|
||||
return [
|
||||
...isolationActions,
|
||||
...(isResponseActionsConsoleEnabled
|
||||
? [
|
||||
{
|
||||
'data-test-subj': 'console',
|
||||
icon: 'console',
|
||||
key: 'consoleLink',
|
||||
onClick: (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
showEndpointResponseActionsConsole(endpointMetadata);
|
||||
},
|
||||
children: (
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.endpoint.actions.console"
|
||||
defaultMessage="Launch responder"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
'data-test-subj': 'hostLink',
|
||||
icon: 'logoSecurity',
|
||||
|
@ -192,5 +217,13 @@ export const useEndpointActionItems = (
|
|||
}
|
||||
|
||||
return [];
|
||||
}, [allCurrentUrlParams, endpointMetadata, fleetAgentPolicies, getAppUrl, isPlatinumPlus]);
|
||||
}, [
|
||||
allCurrentUrlParams,
|
||||
endpointMetadata,
|
||||
fleetAgentPolicies,
|
||||
getAppUrl,
|
||||
isPlatinumPlus,
|
||||
isResponseActionsConsoleEnabled,
|
||||
showEndpointResponseActionsConsole,
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -68,7 +68,6 @@ import {
|
|||
BackToExternalAppButton,
|
||||
BackToExternalAppButtonProps,
|
||||
} from '../../../components/back_to_external_app_button/back_to_external_app_button';
|
||||
import { DevConsole } from './dev_console';
|
||||
import { ManagementEmptyStateWrapper } from '../../../components/management_empty_state_wrapper';
|
||||
|
||||
const MAX_PAGINATED_ITEM = 9999;
|
||||
|
@ -689,9 +688,6 @@ export const EndpointList = () => {
|
|||
}
|
||||
headerBackComponent={routeState.backLink && backToPolicyList}
|
||||
>
|
||||
{/* FIXME: Remove once Console is implemented via ConsoleManagementProvider */}
|
||||
<DevConsole />
|
||||
|
||||
{hasSelectedEndpoint && <EndpointDetailsFlyout />}
|
||||
<>
|
||||
{areEndpointsEnrolling && !hasErrorFindingTotals && (
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 { HttpFetchOptionsWithPath } from '@kbn/core/public';
|
||||
import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
|
||||
import {
|
||||
ACTION_DETAILS_ROUTE,
|
||||
ACTION_STATUS_ROUTE,
|
||||
ISOLATE_HOST_ROUTE,
|
||||
UNISOLATE_HOST_ROUTE,
|
||||
} from '../../../../common/endpoint/constants';
|
||||
import {
|
||||
httpHandlerMockFactory,
|
||||
ResponseProvidersInterface,
|
||||
} from '../../../common/mock/endpoint/http_handler_mock_factory';
|
||||
import {
|
||||
ActionDetailsApiResponse,
|
||||
HostIsolationResponse,
|
||||
PendingActionsResponse,
|
||||
} from '../../../../common/endpoint/types';
|
||||
|
||||
export type ResponseActionsHttpMocksInterface = ResponseProvidersInterface<{
|
||||
isolateHost: () => HostIsolationResponse;
|
||||
|
||||
releaseHost: () => HostIsolationResponse;
|
||||
|
||||
actionDetails: (options: HttpFetchOptionsWithPath) => ActionDetailsApiResponse;
|
||||
|
||||
agentPendingActionsSummary: (options: HttpFetchOptionsWithPath) => PendingActionsResponse;
|
||||
}>;
|
||||
|
||||
export const responseActionsHttpMocks = httpHandlerMockFactory<ResponseActionsHttpMocksInterface>([
|
||||
{
|
||||
id: 'isolateHost',
|
||||
path: ISOLATE_HOST_ROUTE,
|
||||
method: 'post',
|
||||
handler: (): HostIsolationResponse => {
|
||||
return { action: '1-2-3' };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'releaseHost',
|
||||
path: UNISOLATE_HOST_ROUTE,
|
||||
method: 'post',
|
||||
handler: (): HostIsolationResponse => {
|
||||
return { action: '3-2-1' };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actionDetails',
|
||||
path: ACTION_DETAILS_ROUTE,
|
||||
method: 'get',
|
||||
handler: ({ path }): ActionDetailsApiResponse => {
|
||||
const response = new EndpointActionGenerator('seed').generateActionDetails();
|
||||
|
||||
// use the ID of the action in the response
|
||||
response.id = path.substring(path.lastIndexOf('/') + 1) || response.id;
|
||||
|
||||
return { data: response };
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'agentPendingActionsSummary',
|
||||
path: ACTION_STATUS_ROUTE,
|
||||
method: 'get',
|
||||
handler: ({ query }): PendingActionsResponse => {
|
||||
const generator = new EndpointActionGenerator('seed');
|
||||
|
||||
return {
|
||||
data: (query as { agent_ids: string[] }).agent_ids.map((id) =>
|
||||
generator.generateAgentPendingActionsSummary({ agent_id: id })
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
|
@ -753,33 +753,6 @@ exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should r
|
|||
color: #535966;
|
||||
}
|
||||
|
||||
.c2 dt {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.c2 dd {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c2 dd > div {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c1 .euiButtonIcon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
|
@ -808,6 +781,33 @@ exports[`Details Panel Component DetailsPanel:HostDetails: rendering it should r
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.c2 dt {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.c2 dd {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c2 dd > div {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c1 .euiButtonIcon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
padding: 16px;
|
||||
background: rgba(250,251,253,0.9);
|
||||
|
@ -1894,33 +1894,6 @@ exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it shoul
|
|||
color: #535966;
|
||||
}
|
||||
|
||||
.c2 dt {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.c2 dd {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c2 dd > div {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c1 .euiButtonIcon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c0 {
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
|
@ -1949,6 +1922,33 @@ exports[`Details Panel Component DetailsPanel:NetworkDetails: rendering it shoul
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.c2 dt {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.c2 dd {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c2 dd > div {
|
||||
width: -webkit-fit-content;
|
||||
width: -moz-fit-content;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.c1 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.c1 .euiButtonIcon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.c4 {
|
||||
padding: 16px;
|
||||
background: rgba(250,251,253,0.9);
|
||||
|
|
|
@ -13,6 +13,7 @@ import { Actions, isAlert } from '.';
|
|||
import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
|
||||
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
|
||||
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
|
||||
|
||||
jest.mock('../../../../../detections/components/user_info', () => ({
|
||||
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
|
||||
|
@ -20,9 +21,7 @@ jest.mock('../../../../../detections/components/user_info', () => ({
|
|||
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
jest.mock('../../../../../common/hooks/use_selector', () => ({
|
||||
useShallowEqualSelector: jest.fn().mockReturnValue(mockTimelineModel),
|
||||
}));
|
||||
jest.mock('../../../../../common/hooks/use_selector');
|
||||
jest.mock(
|
||||
'../../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline',
|
||||
() => ({
|
||||
|
@ -87,6 +86,10 @@ const defaultProps = {
|
|||
};
|
||||
|
||||
describe('Actions', () => {
|
||||
beforeAll(() => {
|
||||
(useShallowEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel);
|
||||
});
|
||||
|
||||
test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
|
|
|
@ -44,6 +44,8 @@ describe('When using `getActionDetailsById()', () => {
|
|||
agents: ['agent-a'],
|
||||
command: 'isolate',
|
||||
completedAt: '2022-04-30T16:08:47.449Z',
|
||||
wasSuccessful: true,
|
||||
error: undefined,
|
||||
id: '123',
|
||||
isCompleted: true,
|
||||
isExpired: false,
|
||||
|
|
|
@ -110,7 +110,7 @@ export const getActionDetailsById = async (
|
|||
throw new NotFoundError(`Action with id '${actionId}' not found.`);
|
||||
}
|
||||
|
||||
const { isCompleted, completedAt } = getActionCompletionInfo(
|
||||
const { isCompleted, completedAt, wasSuccessful, errors } = getActionCompletionInfo(
|
||||
normalizedActionRequest.agents,
|
||||
actionResponses
|
||||
);
|
||||
|
@ -123,6 +123,8 @@ export const getActionDetailsById = async (
|
|||
logEntries: [...actionRequestsLogEntries, ...actionResponses],
|
||||
isCompleted,
|
||||
completedAt,
|
||||
wasSuccessful,
|
||||
errors,
|
||||
isExpired: !isCompleted && normalizedActionRequest.expiration < new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
|
|
@ -87,11 +87,13 @@ describe('When using Actions service utilities', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#getAction CompletionInfo()', () => {
|
||||
describe('#getActionCompletionInfo()', () => {
|
||||
const COMPLETED_AT = '2022-05-05T18:53:18.836Z';
|
||||
const NOT_COMPLETED_OUTPUT = Object.freeze({
|
||||
isCompleted: false,
|
||||
completed: undefined,
|
||||
completedAt: undefined,
|
||||
wasSuccessful: false,
|
||||
errors: undefined,
|
||||
});
|
||||
|
||||
it('should show complete `false` if no action ids', () => {
|
||||
|
@ -131,7 +133,70 @@ describe('When using Actions service utilities', () => {
|
|||
}),
|
||||
]
|
||||
)
|
||||
).toEqual({ isCompleted: true, completedAt: COMPLETED_AT });
|
||||
).toEqual({
|
||||
isCompleted: true,
|
||||
completedAt: COMPLETED_AT,
|
||||
errors: undefined,
|
||||
wasSuccessful: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('and action failed', () => {
|
||||
let fleetResponseAtError: ActivityLogActionResponse;
|
||||
let endpointResponseAtError: EndpointActivityLogActionResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
fleetResponseAtError = fleetActionGenerator.generateActivityLogActionResponse({
|
||||
item: { data: { agent_id: '123', error: 'agent failed to deliver' } },
|
||||
});
|
||||
|
||||
endpointResponseAtError = endpointActionGenerator.generateActivityLogActionResponse({
|
||||
item: {
|
||||
data: {
|
||||
'@timestamp': '2022-05-18T13:03:54.756Z',
|
||||
agent: { id: '123' },
|
||||
error: {
|
||||
message: 'endpoint failed to apply',
|
||||
},
|
||||
EndpointActions: {
|
||||
completed_at: '2022-05-18T13:03:54.756Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show `wasSuccessful` as `false` if endpoint action response has error', () => {
|
||||
expect(getActionCompletionInfo(['123'], [endpointResponseAtError])).toEqual({
|
||||
completedAt: endpointResponseAtError.item.data['@timestamp'],
|
||||
errors: ['Endpoint action response error: endpoint failed to apply'],
|
||||
isCompleted: true,
|
||||
wasSuccessful: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show `wasSuccessful` as `false` if fleet action response has error (no endpoint response)', () => {
|
||||
expect(getActionCompletionInfo(['123'], [fleetResponseAtError])).toEqual({
|
||||
completedAt: fleetResponseAtError.item.data.completed_at,
|
||||
errors: ['Fleet action response error: agent failed to deliver'],
|
||||
isCompleted: true,
|
||||
wasSuccessful: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include both fleet and endpoint errors if both responses returned failure', () => {
|
||||
expect(
|
||||
getActionCompletionInfo(['123'], [fleetResponseAtError, endpointResponseAtError])
|
||||
).toEqual({
|
||||
completedAt: endpointResponseAtError.item.data['@timestamp'],
|
||||
errors: [
|
||||
'Endpoint action response error: endpoint failed to apply',
|
||||
'Fleet action response error: agent failed to deliver',
|
||||
],
|
||||
isCompleted: true,
|
||||
wasSuccessful: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with multiple agent ids', () => {
|
||||
|
@ -212,7 +277,12 @@ describe('When using Actions service utilities', () => {
|
|||
...action456Responses,
|
||||
...action789Responses,
|
||||
])
|
||||
).toEqual({ isCompleted: true, completedAt: COMPLETED_AT });
|
||||
).toEqual({
|
||||
isCompleted: true,
|
||||
completedAt: COMPLETED_AT,
|
||||
wasSuccessful: true,
|
||||
errors: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should complete as `true` if one agent only received a fleet response with error on it', () => {
|
||||
|
@ -228,7 +298,12 @@ describe('When using Actions service utilities', () => {
|
|||
|
||||
...action789Responses,
|
||||
])
|
||||
).toEqual({ isCompleted: true, completedAt: '2022-05-06T12:50:19.747Z' });
|
||||
).toEqual({
|
||||
completedAt: '2022-05-06T12:50:19.747Z',
|
||||
errors: ['Fleet action response error: something is no good'],
|
||||
isCompleted: true,
|
||||
wasSuccessful: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -85,6 +85,8 @@ export const mapToNormalizedActionRequest = (
|
|||
interface ActionCompletionInfo {
|
||||
isCompleted: boolean;
|
||||
completedAt: undefined | string;
|
||||
wasSuccessful: boolean;
|
||||
errors: undefined | string[];
|
||||
}
|
||||
|
||||
export const getActionCompletionInfo = (
|
||||
|
@ -96,6 +98,8 @@ export const getActionCompletionInfo = (
|
|||
const completedInfo: ActionCompletionInfo = {
|
||||
isCompleted: Boolean(agentIds.length),
|
||||
completedAt: undefined,
|
||||
wasSuccessful: Boolean(agentIds.length),
|
||||
errors: undefined,
|
||||
};
|
||||
|
||||
const responsesByAgentId = mapActionResponsesByAgentId(actionResponses);
|
||||
|
@ -103,12 +107,15 @@ export const getActionCompletionInfo = (
|
|||
for (const agentId of agentIds) {
|
||||
if (!responsesByAgentId[agentId] || !responsesByAgentId[agentId].isCompleted) {
|
||||
completedInfo.isCompleted = false;
|
||||
completedInfo.wasSuccessful = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If completed, then get the completed at date
|
||||
// If completed, then get the completed at date and determine if action was successful or not
|
||||
if (completedInfo.isCompleted) {
|
||||
const responseErrors: ActionCompletionInfo['errors'] = [];
|
||||
|
||||
for (const normalizedAgentResponse of Object.values(responsesByAgentId)) {
|
||||
if (
|
||||
!completedInfo.completedAt ||
|
||||
|
@ -116,6 +123,17 @@ export const getActionCompletionInfo = (
|
|||
) {
|
||||
completedInfo.completedAt = normalizedAgentResponse.completedAt;
|
||||
}
|
||||
|
||||
if (!normalizedAgentResponse.wasSuccessful) {
|
||||
completedInfo.wasSuccessful = false;
|
||||
responseErrors.push(
|
||||
...(normalizedAgentResponse.errors ? normalizedAgentResponse.errors : [])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (responseErrors.length) {
|
||||
completedInfo.errors = responseErrors;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -125,6 +143,8 @@ export const getActionCompletionInfo = (
|
|||
interface NormalizedAgentActionResponse {
|
||||
isCompleted: boolean;
|
||||
completedAt: undefined | string;
|
||||
wasSuccessful: boolean;
|
||||
errors: undefined | string[];
|
||||
fleetResponse: undefined | ActivityLogActionResponse;
|
||||
endpointResponse: undefined | EndpointActivityLogActionResponse;
|
||||
}
|
||||
|
@ -150,6 +170,8 @@ const mapActionResponsesByAgentId = (
|
|||
response[agentId] = {
|
||||
isCompleted: false,
|
||||
completedAt: undefined,
|
||||
wasSuccessful: false,
|
||||
errors: undefined,
|
||||
fleetResponse: undefined,
|
||||
endpointResponse: undefined,
|
||||
};
|
||||
|
@ -172,21 +194,42 @@ const mapActionResponsesByAgentId = (
|
|||
// endpoint, so we are unlikely to ever receive an Endpoint Response.
|
||||
Boolean(thisAgentActionResponses.fleetResponse?.item.data.error);
|
||||
|
||||
// When completed, calculate additional properties about the action
|
||||
if (thisAgentActionResponses.isCompleted) {
|
||||
if (thisAgentActionResponses.endpointResponse) {
|
||||
thisAgentActionResponses.completedAt =
|
||||
thisAgentActionResponses.endpointResponse?.item.data['@timestamp'];
|
||||
thisAgentActionResponses.wasSuccessful = true;
|
||||
} else if (
|
||||
thisAgentActionResponses.fleetResponse &&
|
||||
thisAgentActionResponses.fleetResponse?.item.data.error
|
||||
) {
|
||||
// Check if perhaps the Fleet action response returned an error, in which case, the Fleet Agent
|
||||
// failed to deliver the Action to the Endpoint. If that's the case, we are not going to get
|
||||
// a Response from endpoint, thus mark the Action as completed and use the Fleet Message's
|
||||
// timestamp for the complete data/time.
|
||||
thisAgentActionResponses.fleetResponse &&
|
||||
thisAgentActionResponses.fleetResponse.item.data.error
|
||||
) {
|
||||
thisAgentActionResponses.isCompleted = true;
|
||||
thisAgentActionResponses.completedAt =
|
||||
thisAgentActionResponses.fleetResponse?.item.data['@timestamp'];
|
||||
thisAgentActionResponses.fleetResponse.item.data['@timestamp'];
|
||||
}
|
||||
|
||||
const errors: NormalizedAgentActionResponse['errors'] = [];
|
||||
|
||||
if (thisAgentActionResponses.endpointResponse?.item.data.error?.message) {
|
||||
errors.push(
|
||||
`Endpoint action response error: ${thisAgentActionResponses.endpointResponse.item.data.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (thisAgentActionResponses.fleetResponse?.item.data.error) {
|
||||
errors.push(
|
||||
`Fleet action response error: ${thisAgentActionResponses.fleetResponse?.item.data.error}`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
thisAgentActionResponses.wasSuccessful = false;
|
||||
thisAgentActionResponses.errors = errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23723,7 +23723,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "argument non pris en charge : {argName}",
|
||||
"xpack.securitySolution.console.unknownCommand.helpMessage": "Pour obtenir la liste des commandes disponibles, entrez : {helpCmd}.",
|
||||
"xpack.securitySolution.console.unknownCommand.title": "Commande inconnue",
|
||||
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "Consoles de point de terminaison",
|
||||
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "Impossible d'interroger les données d'anomalies",
|
||||
"xpack.securitySolution.containers.anomalies.stackByJobId": "tâche",
|
||||
"xpack.securitySolution.containers.anomalies.title": "Anomalies",
|
||||
|
|
|
@ -23860,7 +23860,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "サポートされていない引数:{argName}",
|
||||
"xpack.securitySolution.console.unknownCommand.helpMessage": "使用可能なコマンドのリストについては、{helpCmd}を入力します",
|
||||
"xpack.securitySolution.console.unknownCommand.title": "不明なコマンド",
|
||||
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "エンドポイントコンソール",
|
||||
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "異常データをクエリできませんでした",
|
||||
"xpack.securitySolution.containers.anomalies.stackByJobId": "ジョブ",
|
||||
"xpack.securitySolution.containers.anomalies.title": "異常",
|
||||
|
|
|
@ -23892,7 +23892,6 @@
|
|||
"xpack.securitySolution.console.commandValidation.unsupportedArg": "不支持的参数:{argName}",
|
||||
"xpack.securitySolution.console.unknownCommand.helpMessage": "如需可用命令列表,请输入:{helpCmd}",
|
||||
"xpack.securitySolution.console.unknownCommand.title": "未知命令",
|
||||
"xpack.securitySolution.consolesPopoverHeaderItem.buttonLabel": "终端控制台",
|
||||
"xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "无法查询异常数据",
|
||||
"xpack.securitySolution.containers.anomalies.stackByJobId": "作业",
|
||||
"xpack.securitySolution.containers.anomalies.title": "异常",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue