From 692e3596cb99ae0d33ce9ab378ab47cd10fbfcd9 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Thu, 13 Aug 2020 16:56:01 -0400 Subject: [PATCH] [Resolver] Improve simulator. Add more click-through tests and panel tests. (#74601) (#74791) ### Improved the simulator. * Replace `mapStateTransitions` with `map`. The usage and interface are the same, but `map` is not dependent on redux state. This will work for parts of the app that don't use redux (aka EUI). `map` also forces any `AutoSizer` instances used by EUI to show their full contents. `AutoSizer` works but it doesn't behave as expected in JSDOM. With this hack in place, we can bypass `AutoSizer`. Going forward, we should make sure to use something other than `EuiSelectable` for the dropdowns * Removed the `connectEnzymeWrapperAndStore` test helper. The new `map` simulator method doesn't rely on redux so we no longer need this explicit sync. * The simulator can receive a memory history instance. This allows tests to pass in a precreated / controlled memory instance. Useful for testing the query string. This design is not final. Instead we could have an 'intiialHistorySearch' parameter that sets the query string on instantiation as well as 'pushHistory' and 'replaceHistory' methods? * `findInDom` is now called `domNodes`. * `processNodeElementLooksSelected` and `processNodeElementLooksUnselected` are gone. Instead use `selectedProcessNode` and `unselectedProcessNode` to find the wrappers and assert that they wrappers contain the nodes you are interested in. * Added `processNodeSubmenu` method that gets the submenu that comes up when you click the events button on a process node. * Added `nodeListElement` method. This returns the list of nodes that shows up in the panel. Name is not final. * Added `nodeListItems` method. This returns the list item elements in the node list. Name is not final. * Added `nodeListNodeLinks` method. This returns the links in the items in the node list. Name is not final. * Added `nodeDetailElement` method. This gets the element that contains details about a node. Name is not final. * Added `nodeDetailBreadcrumbNodeListLink` method. Returns the link rendered in the breadcrumbs of the node detail view. Takes the user to the node list. Name is not final. * Added `nodeDetailViewTitle` method. This returns the title of the node detail view. Name is not final. * Added `nodeDetailDescriptionListEntries` method. This returns an entries list of the details in the node detail view. Name is not final * Added `resolveWrapper` method. Pass this a function that returns a `ReactWrapper`. The method will evaluate the returned wrapper after each event loop and return it once it isn't empty. ### Improved our mocks * We had a DataAccessLayer and ResolverTree mock named 'one_ancestor_two_children` that actually had no ancestors. Renamed them to `no_ancestors_two_children`. * New DataAccessLayer mock called `noAncestorsTwoChildrenWithRelatedEventsOnOrigin` ### Added new 'clickthrough' suite test * Added new test in the 'clickthrough' suite that asserts that a user can click the 'related events' button on a node and see the list of related event categories in the submenu. ### Improved the Resolver event model * Added `timestampAsDateSafeVersion` to the event model. This gets a `Date` object for the timestamp. (We still need make it clear that this model is ResolverSpecific) ### New `urlSearch` test helper. Use `urlSearch` when testing Resolver's interaction with the browser location. It calculates the expected 'search' value based on some Resolver specific parameters. * Use this to calculate a URL and then populate the memory history with this URL. This will allow you to see if Resolver loads correctly based on the URL state. * Use this to calculate the expected URL based on Resolver's current state. ### Added new 'panel' test * If Resolver is loaded with a url search parameter that selects a node, the node's details are shown in the panel. * When a history.push occurs that sets a search parameter that selects a node, the details of that node are shown. * Check that the url search is updated when the user interacts with the panel * Check that the panel shows the correct details for a node. (except for the timestamp. See TODO) ### Changed `data-test-subj`s * Removed `resolver:panel`. This was used on a wrapper element that we expect to remove soon. * Added `resolver:node-detail:breadcrumbs:node-list-link` for the buttons in the breadcrumb in the panel. * Added `resolver:node-detail:title` for the title element in the node detail view. * Added `resolver:node-detail:entry-title` and `resolver:node-detail:entry-description` for the details shown about a process in the node detail view. * Added `resolver:node-list:node-link`. This is the link shown for each node in the node list. * added `resolver:node-list:item` to each list item in the node list view. ### Removed dead code * `map.tsx` wasn't being used. It was renamed but the old version wasn't deleted. ### Improved the node detail view * Show the timestamp for a node's process event even if the timestamp is the unix epoch. Note: this is technically a bug fix but the bug is very obscure. * Show the PID for a node's process event when the PID is 0. Note: this is a bug fix. Co-authored-by: Elastic Machine --- .../common/endpoint/models/event.ts | 19 +++ .../public/resolver/mocks/endpoint_event.ts | 4 +- .../public/resolver/mocks/resolver_tree.ts | 3 + .../connect_enzyme_wrapper_and_store.ts | 20 --- .../test_utilities/simulator/index.tsx | 69 +++++--- .../resolver/test_utilities/url_search.ts | 26 +++ .../public/resolver/view/assets.tsx | 14 +- .../resolver/view/clickthrough.test.tsx | 20 ++- .../public/resolver/view/map.tsx | 129 -------------- .../public/resolver/view/panel.test.tsx | 158 +++++++++++++++--- .../resolver/view/panels/cube_for_process.tsx | 67 +++++--- .../public/resolver/view/panels/index.tsx | 2 +- .../resolver/view/panels/process_details.tsx | 14 +- .../view/panels/process_list_with_counts.tsx | 26 +-- .../public/resolver/view/submenu.tsx | 32 ++-- .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- 17 files changed, 336 insertions(+), 271 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/url_search.ts delete mode 100644 x-pack/plugins/security_solution/public/resolver/view/map.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 30e11819c0272..a0e9be58911c6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -55,6 +55,25 @@ export function timestampSafeVersion(event: SafeResolverEvent): string | undefin : firstNonNullValue(event?.['@timestamp']); } +/** + * The `@timestamp` for the event, as a `Date` object. + * If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`. + */ +export function timestampAsDateSafeVersion(event: SafeResolverEvent): Date | undefined { + const value = timestampSafeVersion(event); + if (value === undefined) { + return undefined; + } + + const date = new Date(value); + // Check if the date is valid + if (isFinite(date.getTime())) { + return date; + } else { + return undefined; + } +} + export function eventTimestamp(event: ResolverEvent): string | undefined | number { if (isLegacyEvent(event)) { return event.endgame.timestamp_utc; diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts index c822fdf647c16..083f6b8baa59f 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts @@ -15,12 +15,14 @@ export function mockEndpointEvent({ parentEntityId, timestamp, lifecycleType, + pid = 0, }: { entityID: string; name: string; parentEntityId?: string; timestamp: number; lifecycleType?: string; + pid?: number; }): EndpointEvent { return { '@timestamp': timestamp, @@ -45,7 +47,7 @@ export function mockEndpointEvent({ executable: 'executable', args: 'args', name, - pid: 0, + pid, hash: { md5: 'hash.md5', }, diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index 5d2cbb2eab0dc..7edf4f8071ed8 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -175,18 +175,21 @@ export function mockTreeWithNoAncestorsAnd2Children({ secondChildID: string; }): ResolverTree { const origin: ResolverEvent = mockEndpointEvent({ + pid: 0, entityID: originID, name: 'c', parentEntityId: 'none', timestamp: 0, }); const firstChild: ResolverEvent = mockEndpointEvent({ + pid: 1, entityID: firstChildID, name: 'd', parentEntityId: originID, timestamp: 1, }); const secondChild: ResolverEvent = mockEndpointEvent({ + pid: 2, entityID: secondChildID, name: 'e', parentEntityId: originID, diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts deleted file mode 100644 index 3a4a1f7d634d1..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts +++ /dev/null @@ -1,20 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Store } from 'redux'; -import { ReactWrapper } from 'enzyme'; - -/** - * We use the full-DOM emulation mode of `enzyme` via `mount`. Even though we use `react-redux`, `enzyme` - * does not update the DOM after state transitions. This subscribes to the `redux` store and after any state - * transition it asks `enzyme` to update the DOM to match the React state. - */ -export function connectEnzymeWrapperAndStore(store: Store, wrapper: ReactWrapper): void { - store.subscribe(() => { - // See https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/update.html - return wrapper.update(); - }); -} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 6f44c5aee7cac..cae6a18576ebd 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -10,7 +10,6 @@ import { mount, ReactWrapper } from 'enzyme'; import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; import { CoreStart } from '../../../../../../../src/core/public'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { connectEnzymeWrapperAndStore } from '../connect_enzyme_wrapper_and_store'; import { spyMiddlewareFactory } from '../spy_middleware_factory'; import { resolverMiddlewareFactory } from '../../store/middleware'; import { resolverReducer } from '../../store/reducer'; @@ -48,6 +47,7 @@ export class Simulator { dataAccessLayer, resolverComponentInstanceID, databaseDocumentID, + history, }: { /** * A (mock) data access layer that will be used to create the Resolver store. @@ -61,6 +61,7 @@ export class Simulator { * a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer. */ databaseDocumentID?: string; + history?: HistoryPackageHistoryInterface; }) { this.resolverComponentInstanceID = resolverComponentInstanceID; // create the spy middleware (for debugging tests) @@ -79,8 +80,9 @@ export class Simulator { // Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware` this.store = createStore(resolverReducer, middlewareEnhancer); - // Create a fake 'history' instance that Resolver will use to read and write query string values - this.history = createMemoryHistory(); + // If needed, create a fake 'history' instance. + // Resolver will use to read and write query string values. + this.history = history ?? createMemoryHistory(); // Used for `KibanaContextProvider` const coreStart: CoreStart = coreMock.createStart(); @@ -95,9 +97,6 @@ export class Simulator { databaseDocumentID={databaseDocumentID} /> ); - - // Update the enzyme wrapper after each state transition - connectEnzymeWrapperAndStore(this.store, this.wrapper); } /** @@ -112,6 +111,16 @@ export class Simulator { return this.spyMiddleware.debugActions(); } + /** + * EUI uses a component called `AutoSizer` that won't render its children unless it has sufficient size. + * This forces any `AutoSizer` instances to have a large size. + */ + private forceAutoSizerOpen() { + this.wrapper + .find('AutoSizer') + .forEach((wrapper) => wrapper.setState({ width: 10000, height: 10000 })); + } + /** * Yield the result of `mapper` over and over, once per event-loop cycle. * After 10 times, quit. @@ -124,6 +133,7 @@ export class Simulator { yield mapper(); await new Promise((resolve) => { setTimeout(() => { + this.forceAutoSizerOpen(); this.wrapper.update(); resolve(); }, 0); @@ -174,6 +184,13 @@ export class Simulator { ); } + /** + * The items in the submenu that is opened by expanding a node in the map. + */ + public processNodeSubmenuItems(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:map:node-submenu-item"]'); + } + /** * Return the selected node query string values. */ @@ -206,38 +223,38 @@ export class Simulator { } /** - * An element with a list of all nodes. + * The titles of the links that select a node in the node list view. */ - public nodeListElement(): ReactWrapper { - return this.domNodes('[data-test-subj="resolver:node-list"]'); + public nodeListNodeLinkText(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-list:node-link:title"]'); } /** - * Return the items in the node list (the default panel view.) + * The icons in the links that select a node in the node list view. */ - public nodeListItems(): ReactWrapper { - return this.domNodes('[data-test-subj="resolver:node-list:item"]'); + public nodeListNodeLinkIcons(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-list:node-link:icon"]'); } /** - * The element containing the details for the selected node. + * Link rendered in the breadcrumbs of the node detail view. Takes the user to the node list. */ - public nodeDetailElement(): ReactWrapper { - return this.domNodes('[data-test-subj="resolver:node-detail"]'); + public nodeDetailBreadcrumbNodeListLink(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail:breadcrumbs:node-list-link"]'); } /** - * The details of the selected node are shown in a description list. This returns the title elements of the description list. + * The title element for the node detail view. */ - private nodeDetailEntryTitle(): ReactWrapper { - return this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]'); + public nodeDetailViewTitle(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail:title"]'); } /** - * The details of the selected node are shown in a description list. This returns the description elements of the description list. + * The icon element for the node detail title. */ - private nodeDetailEntryDescription(): ReactWrapper { - return this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]'); + public nodeDetailViewTitleIcon(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail:title-icon"]'); } /** @@ -253,8 +270,14 @@ export class Simulator { * The titles and descriptions (as text) from the node detail panel. */ public nodeDetailDescriptionListEntries(): Array<[string, string]> { - const titles = this.nodeDetailEntryTitle(); - const descriptions = this.nodeDetailEntryDescription(); + /** + * The details of the selected node are shown in a description list. This returns the title elements of the description list. + */ + const titles = this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]'); + /** + * The details of the selected node are shown in a description list. This returns the description elements of the description list. + */ + const descriptions = this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]'); const entries: Array<[string, string]> = []; for (let index = 0; index < Math.min(titles.length, descriptions.length); index++) { const title = titles.at(index).text(); diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/url_search.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/url_search.ts new file mode 100644 index 0000000000000..1a26e29d22da7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/url_search.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface Options { + /** + * The entity_id of the selected node. + */ + selectedEntityID?: string; +} + +/** + * Calculate the expected URL search based on options. + */ +export function urlSearch(resolverComponentInstanceID: string, options?: Options): string { + if (!options) { + return ''; + } + const params = new URLSearchParams(); + if (options.selectedEntityID !== undefined) { + params.set(`resolver-${resolverComponentInstanceID}-id`, options.selectedEntityID); + } + return params.toString(); +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx index fc4a9daf17ad1..6962d300f7072 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + import React, { memo } from 'react'; import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json'; import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json'; @@ -11,7 +13,7 @@ import { htmlIdGenerator, ButtonColor } from '@elastic/eui'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { useUiSetting } from '../../common/lib/kibana'; -import { DEFAULT_DARK_MODE } from '../../../common/constants'; +import { DEFAULT_DARK_MODE as defaultDarkMode } from '../../../common/constants'; import { ResolverProcessType } from '../types'; type ResolverColorNames = @@ -141,8 +143,6 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => ( )); -PaintServers.displayName = 'PaintServers'; - /** * Ids of symbols to be linked by elements */ @@ -376,8 +376,6 @@ const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => ( )); -SymbolsAndShapes.displayName = 'SymbolsAndShapes'; - /** * This `` element is used to define the reusable assets for the Resolver * It confers several advantages, including but not limited to: @@ -386,7 +384,7 @@ SymbolsAndShapes.displayName = 'SymbolsAndShapes'; * 3. `` elements can be handled by compositor (faster) */ const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) => { - const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); + const isDarkMode = useUiSetting(defaultDarkMode); return ( @@ -397,8 +395,6 @@ const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) ); }); -SymbolDefinitionsComponent.displayName = 'SymbolDefinitions'; - export const SymbolDefinitions = styled(SymbolDefinitionsComponent)` position: absolute; left: 100%; @@ -424,7 +420,7 @@ export const useResolverTheme = (): { nodeAssets: NodeStyleMap; cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessTrigger: boolean) => NodeStyleConfig; } => { - const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); + const isDarkMode = useUiSetting(defaultDarkMode); const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; const getThemedOption = (lightOption: string, darkOption: string): string => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 98ea235d3524f..296e5b253c0b9 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -71,8 +71,9 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', }); }); - it(`should show the node list`, async () => { - await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1); + it(`should show links to the 3 nodes (with icons) in the node list.`, async () => { + await expect(simulator.map(() => simulator.nodeListNodeLinkText().length)).toYieldEqualTo(3); + await expect(simulator.map(() => simulator.nodeListNodeLinkIcons().length)).toYieldEqualTo(3); }); describe("when the second child node's first button has been clicked", () => { @@ -152,5 +153,20 @@ describe('Resolver, when analyzing a tree that has two related events for the or relatedEventButtons: 1, }); }); + describe('when the related events button is clicked', () => { + beforeEach(async () => { + const button = await simulator.resolveWrapper(() => + simulator.processNodeRelatedEventButton(entityIDs.origin) + ); + if (button) { + button.simulate('click'); + } + }); + it('should open the submenu', async () => { + await expect( + simulator.map(() => simulator.processNodeSubmenuItems().map((node) => node.text())) + ).toYieldEqualTo(['2 registry']); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx deleted file mode 100644 index bbff2388af8b7..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ /dev/null @@ -1,129 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable react/display-name */ - -import React, { useContext } from 'react'; -import { useSelector } from 'react-redux'; -import { useEffectOnce } from 'react-use'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import * as selectors from '../store/selectors'; -import { EdgeLine } from './edge_line'; -import { GraphControls } from './graph_controls'; -import { ProcessEventDot } from './process_event_dot'; -import { useCamera } from './use_camera'; -import { SymbolDefinitions, useResolverTheme } from './assets'; -import { useStateSyncingActions } from './use_state_syncing_actions'; -import { useResolverQueryParams } from './use_resolver_query_params'; -import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; -import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; -import { SideEffectContext } from './side_effect_context'; - -/** - * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. - */ -export const ResolverMap = React.memo(function ({ - className, - databaseDocumentID, - resolverComponentInstanceID, -}: { - /** - * Used by `styled-components`. - */ - className?: string; - /** - * The `_id` value of an event in ES. - * Used as the origin of the Resolver graph. - */ - databaseDocumentID?: string; - /** - * A string literal describing where in the app resolver is located, - * used to prevent collisions in things like query params - */ - resolverComponentInstanceID: string; -}) { - /** - * This is responsible for dispatching actions that include any external data. - * `databaseDocumentID` - */ - useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); - - const { timestamp } = useContext(SideEffectContext); - - // use this for the entire render in order to keep things in sync - const timeAtRender = timestamp(); - - const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleNodesAndEdgeLines - )(timeAtRender); - const terminatedProcesses = useSelector(selectors.terminatedProcesses); - const { projectionMatrix, ref, onMouseDown } = useCamera(); - const isLoading = useSelector(selectors.isLoading); - const hasError = useSelector(selectors.hasError); - const activeDescendantId = useSelector(selectors.ariaActiveDescendant); - const { colorMap } = useResolverTheme(); - const { cleanUpQueryParams } = useResolverQueryParams(); - - useEffectOnce(() => { - return () => cleanUpQueryParams(); - }); - - return ( - - {isLoading ? ( -
- -
- ) : hasError ? ( -
-
- {' '} - -
-
- ) : ( - - {connectingEdgeLineSegments.map(({ points: [startPosition, endPosition], metadata }) => ( - - ))} - {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityIDSafeVersion(processEvent); - return ( - - ); - })} - - )} - - - - - ); -}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 78e5fd79bea13..4d391a6c9ce59 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -4,47 +4,143 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; + import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher import '../test_utilities/extend_jest'; +import { urlSearch } from '../test_utilities/url_search'; + +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'resolverComponentInstanceID'; -describe('Resolver: when analyzing a tree with no ancestors and two children', () => { - let simulator: Simulator; - let databaseDocumentID: string; +describe(`Resolver: when analyzing a tree with no ancestors and two children, and when the component instance ID is ${resolverComponentInstanceID}`, () => { + /** + * Get (or lazily create and get) the simulator. + */ + let simulator: () => Simulator; + /** lazily populated by `simulator`. */ + let simulatorInstance: Simulator | undefined; + let memoryHistory: HistoryPackageHistoryInterface; - // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances - const resolverComponentInstanceID = 'resolverComponentInstanceID'; + // node IDs used by the generator + let entityIDs: { + origin: string; + firstChild: string; + secondChild: string; + }; - beforeEach(async () => { + beforeEach(() => { // create a mock data access layer const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren(); - // save a reference to the `_id` supported by the mock data layer - databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + entityIDs = dataAccessLayerMetadata.entityIDs; + + memoryHistory = createMemoryHistory(); // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = () => { + if (simulatorInstance) { + return simulatorInstance; + } else { + simulatorInstance = new Simulator({ + databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + history: memoryHistory, + }); + return simulatorInstance; + } + }; }); - it('should show the node list', async () => { - await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1); + afterEach(() => { + simulatorInstance = undefined; }); - it('should have 3 nodes in the node list', async () => { - await expect(simulator.map(() => simulator.nodeListItems().length)).toYieldEqualTo(3); + const queryStringWithOriginSelected = urlSearch(resolverComponentInstanceID, { + selectedEntityID: 'origin', }); - describe('when there is an item in the node list and it has been clicked', () => { + + describe(`when the URL query string is ${queryStringWithOriginSelected}`, () => { + beforeEach(() => { + memoryHistory.push({ + search: queryStringWithOriginSelected, + }); + }); + it('should show the node details for the origin', async () => { + await expect( + simulator().map(() => { + const titleWrapper = simulator().nodeDetailViewTitle(); + const titleIconWrapper = simulator().nodeDetailViewTitleIcon(); + return { + title: titleWrapper.exists() ? titleWrapper.text() : null, + titleIcon: titleIconWrapper.exists() ? titleIconWrapper.text() : null, + detailEntries: simulator().nodeDetailDescriptionListEntries(), + }; + }) + ).toYieldEqualTo({ + title: 'c', + titleIcon: 'Running Process', + detailEntries: [ + ['process.executable', 'executable'], + ['process.pid', '0'], + ['user.name', 'user.name'], + ['user.domain', 'user.domain'], + ['process.parent.pid', '0'], + ['process.hash.md5', 'hash.md5'], + ['process.args', 'args'], + ], + }); + }); + }); + + const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, { + selectedEntityID: 'firstChild', + }); + + describe(`when the URL query string is ${queryStringWithFirstChildSelected}`, () => { + beforeEach(() => { + memoryHistory.push({ + search: queryStringWithFirstChildSelected, + }); + }); + it('should show the node details for the first child', async () => { + await expect( + simulator().map(() => simulator().nodeDetailDescriptionListEntries()) + ).toYieldEqualTo([ + ['process.executable', 'executable'], + ['process.pid', '1'], + ['user.name', 'user.name'], + ['user.domain', 'user.domain'], + ['process.parent.pid', '0'], + ['process.hash.md5', 'hash.md5'], + ['process.args', 'args'], + ]); + }); + }); + + it('should have 3 nodes (with icons) in the node list', async () => { + await expect(simulator().map(() => simulator().nodeListNodeLinkText().length)).toYieldEqualTo( + 3 + ); + await expect(simulator().map(() => simulator().nodeListNodeLinkIcons().length)).toYieldEqualTo( + 3 + ); + }); + + describe('when there is an item in the node list and its text has been clicked', () => { beforeEach(async () => { - const nodeListItems = await simulator.resolveWrapper(() => simulator.nodeListItems()); - expect(nodeListItems && nodeListItems.length).toBeTruthy(); - if (nodeListItems) { - nodeListItems.first().find('button').simulate('click'); + const nodeLinks = await simulator().resolveWrapper(() => simulator().nodeListNodeLinkText()); + expect(nodeLinks).toBeTruthy(); + if (nodeLinks) { + nodeLinks.first().simulate('click'); } }); it('should show the details for the first node', async () => { await expect( - simulator.map(() => simulator.nodeDetailDescriptionListEntries()) + simulator().map(() => simulator().nodeDetailDescriptionListEntries()) ).toYieldEqualTo([ ['process.executable', 'executable'], ['process.pid', '0'], @@ -55,5 +151,29 @@ describe('Resolver: when analyzing a tree with no ancestors and two children', ( ['process.args', 'args'], ]); }); + it("should have the first node's ID in the query string", async () => { + await expect(simulator().map(() => simulator().queryStringValues())).toYieldEqualTo({ + selectedNode: [entityIDs.origin], + }); + }); + describe('and when the node list link has been clicked', () => { + beforeEach(async () => { + const nodeListLink = await simulator().resolveWrapper(() => + simulator().nodeDetailBreadcrumbNodeListLink() + ); + if (nodeListLink) { + nodeListLink.simulate('click'); + } + }); + it('should show the list of nodes with links to each node', async () => { + await expect( + simulator().map(() => { + return simulator() + .nodeListNodeLinkText() + .map((node) => node.text()); + }) + ).toYieldEqualTo(['c', 'd', 'e']); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx index 0d8f65b4e39e6..b7c8ed0dfd7db 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx @@ -4,42 +4,55 @@ * you may not use this file except in compliance with the Elastic License. */ +import styled from 'styled-components'; + +import { i18n } from '@kbn/i18n'; + +/* eslint-disable react/display-name */ + import React, { memo } from 'react'; import { useResolverTheme } from '../assets'; /** - * During user testing, one user indicated they wanted to see stronger visual relationships between - * Nodes on the graph and what's in the table. Using the same symbol in both places (as below) could help with that. + * Icon representing a process node. */ -export const CubeForProcess = memo(function CubeForProcess({ - isProcessTerminated, +export const CubeForProcess = memo(function ({ + running, + 'data-test-subj': dataTestSubj, }: { - isProcessTerminated: boolean; + 'data-test-subj'?: string; + /** + * True if the process represented by the node is still running. + */ + running: boolean; }) { const { cubeAssetsForNode } = useResolverTheme(); - const { cubeSymbol, descriptionText } = cubeAssetsForNode(isProcessTerminated, false); + const { cubeSymbol } = cubeAssetsForNode(!running, false); return ( - <> - - {descriptionText} - - - + + + {i18n.translate('xpack.securitySolution.resolver.node_icon', { + defaultMessage: '{running, select, true {Running Process} false {Terminated Process}}', + values: { running }, + })} + + + ); }); + +const StyledSVG = styled.svg` + position: relative; + top: 0.4em; + margin-right: 0.25em; +`; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index 7e7e8b757baf7..b3c4eefe5fae7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -220,7 +220,7 @@ PanelContent.displayName = 'PanelContent'; export const Panel = memo(function Event({ className }: { className?: string }) { return ( - + ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx index 112a3400c4947..adfcc4cc44d1f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_details.tsx @@ -129,6 +129,7 @@ export const ProcessDetails = memo(function ProcessDetails({ defaultMessage: 'Events', } ), + 'data-test-subj': 'resolver:node-detail:breadcrumbs:node-list-link', onClick: () => { pushToQueryParams({ crumbId: '', crumbEvent: '' }); }, @@ -155,20 +156,23 @@ export const ProcessDetails = memo(function ProcessDetails({ return cubeAssetsForNode(isProcessTerminated, false); }, [processEvent, cubeAssetsForNode, isProcessTerminated]); - const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); + const titleID = useMemo(() => htmlIdGenerator('resolverTable')(), []); return ( <> -

- - {processName} +

+ + {processName}

- {descriptionText} + {descriptionText} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx index 11f005f8acbcd..1be4b4b055243 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_list_with_counts.tsx @@ -111,8 +111,11 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ }); }} > - - {name} + + {name} ); }, @@ -150,18 +153,10 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map((processEvent) => { - let dateTime: Date | undefined; - const eventTime = event.timestampSafeVersion(processEvent); const name = event.processNameSafeVersion(processEvent); - if (eventTime) { - const date = new Date(eventTime); - if (isFinite(date.getTime())) { - dateTime = date; - } - } return { name, - timestamp: dateTime, + timestamp: event.timestampAsDateSafeVersion(processEvent), event: processEvent, }; }), @@ -172,12 +167,9 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ const crumbs = useMemo(() => { return [ { - text: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events', - { - defaultMessage: 'All Process Events', - } - ), + text: i18n.translate('xpack.securitySolution.resolver.panel.nodeList.title', { + defaultMessage: 'All Process Events', + }), onClick: () => {}, }, ]; diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 7f0ba244146fd..359a4e2dafd2e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-duplicate-imports */ + +/* eslint-disable react/display-name */ + import { i18n } from '@kbn/i18n'; -import React, { ReactNode, useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react'; import { EuiI18nNumber, EuiSelectable, @@ -15,6 +19,7 @@ import { htmlIdGenerator, } from '@elastic/eui'; import styled from 'styled-components'; +import { EuiSelectableOption } from '@elastic/eui'; import { Matrix3 } from '../types'; /** @@ -59,21 +64,21 @@ const OptionList = React.memo( subMenuOptions: ResolverSubmenuOptionList; isLoading: boolean; }) => { - const [options, setOptions] = useState(() => + const [options, setOptions] = useState(() => typeof subMenuOptions !== 'object' ? [] - : subMenuOptions.map((opt: ResolverSubmenuOption): { - label: string; - prepend?: ReactNode; - } => { - return opt.prefix + : subMenuOptions.map((option: ResolverSubmenuOption) => { + const dataTestSubj = 'resolver:map:node-submenu-item'; + return option.prefix ? { - label: opt.optionTitle, - prepend: {opt.prefix} , + label: option.optionTitle, + prepend: {option.prefix} , + 'data-test-subj': dataTestSubj, } : { - label: opt.optionTitle, + label: option.optionTitle, prepend: , + 'data-test-subj': dataTestSubj, }; }) ); @@ -88,11 +93,10 @@ const OptionList = React.memo( }, {}); }, [subMenuOptions]); - type ChangeOptions = Array<{ label: string; prepend?: ReactNode; checked?: string }>; const selectableProps = useMemo(() => { return { listProps: { showIcons: true, bordered: true }, - onChange: (newOptions: ChangeOptions) => { + onChange: (newOptions: EuiSelectableOption[]) => { const selectedOption = newOptions.find((opt) => opt.checked === 'on'); if (selectedOption) { const { label } = selectedOption; @@ -119,8 +123,6 @@ const OptionList = React.memo( } ); -OptionList.displayName = 'OptionList'; - /** * A Submenu to be displayed in one of two forms: * 1) Provided a collection of `optionsWithActions`: it will call `menuAction` then - if and when menuData becomes available - display each item with an optional prefix and call the supplied action for the options when that option is clicked. @@ -259,8 +261,6 @@ const NodeSubMenuComponents = React.memo( } ); -NodeSubMenuComponents.displayName = 'NodeSubMenu'; - export const NodeSubMenu = styled(NodeSubMenuComponents)` margin: 2px 0 0 0; padding: 0; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index df16a67cfb081..492e130be7f1b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16368,7 +16368,7 @@ "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "イベント", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "イベントを待機しています...", - "xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events": "すべてのプロセスイベント", + "xpack.securitySolution.resolver.panel.nodeList.title": "すべてのプロセスイベント", "xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount}件のイベント", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "関連イベントが見つかりません。", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "イベントを待機しています...", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9230cfa846b51..611a46b224b1f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16374,7 +16374,7 @@ "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "事件", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait": "等候事件......", - "xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events": "所有进程事件", + "xpack.securitySolution.resolver.panel.nodeList.title": "所有进程事件", "xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount} 个事件", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "找不到相关事件。", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "等候事件......",