diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts index be0bc1b812a0b..94c176d343d17 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts @@ -10,7 +10,10 @@ import { ResolverEntityIndex, } from '../../../../common/endpoint/types'; import { mockEndpointEvent } from '../../store/mocks/endpoint_event'; -import { mockTreeWithNoAncestorsAnd2Children } from '../../store/mocks/resolver_tree'; +import { + mockTreeWithNoAncestorsAnd2Children, + withRelatedEventsOnOrigin, +} from '../../store/mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; interface Metadata { @@ -40,11 +43,24 @@ interface Metadata { /** * A simple mock dataAccessLayer possible that returns a tree with 0 ancestors and 2 direct children. 1 related event is returned. The parameter to `entities` is ignored. */ -export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { +export function oneAncestorTwoChildren( + { withRelatedEvents }: { withRelatedEvents: Iterable<[string, string]> | null } = { + withRelatedEvents: null, + } +): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { const metadata: Metadata = { databaseDocumentID: '_id', entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, }; + const baseTree = mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }); + const composedTree = withRelatedEvents + ? withRelatedEventsOnOrigin(baseTree, withRelatedEvents) + : baseTree; + return { metadata, dataAccessLayer: { @@ -54,13 +70,17 @@ export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; me relatedEvents(entityID: string): Promise { return Promise.resolve({ entityID, - events: [ - mockEndpointEvent({ - entityID, - name: 'event', - timestamp: 0, - }), - ], + events: + /* Respond with the mocked related events when the origin's related events are fetched*/ withRelatedEvents && + entityID === metadata.entityIDs.origin + ? composedTree.relatedEvents.events + : [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], nextEvent: null, }); }, @@ -69,13 +89,7 @@ export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; me * Fetch a ResolverTree for a entityID */ resolverTree(): Promise { - return Promise.resolve( - mockTreeWithNoAncestorsAnd2Children({ - originID: metadata.entityIDs.origin, - firstChildID: metadata.entityIDs.firstChild, - secondChildID: metadata.entityIDs.secondChild, - }) - ); + return Promise.resolve(composedTree); }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts new file mode 100644 index 0000000000000..1e0c460a3a711 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts @@ -0,0 +1,36 @@ +/* + * 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 { EndpointEvent } from '../../../../common/endpoint/types'; + +/** + * Simple mock related event. + */ +export function mockRelatedEvent({ + entityID, + timestamp, + category, + type, + id, +}: { + entityID: string; + timestamp: number; + category: string; + type: string; + id?: string; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + kind: 'event', + type, + category, + id: id ?? 'xyz', + }, + process: { + entity_id: entityID, + }, + } as EndpointEvent; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts index 6a8ab61ccf9b6..21d0309501aa8 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -5,6 +5,7 @@ */ import { mockEndpointEvent } from './endpoint_event'; +import { mockRelatedEvent } from './related_event'; import { ResolverTree, ResolverEvent } from '../../../../common/endpoint/types'; export function mockTreeWith2AncestorsAndNoChildren({ @@ -109,6 +110,58 @@ export function mockTreeWithAllProcessesTerminated({ } as unknown) as ResolverTree; } +/** + * A valid category for a related event. E.g. "registry", "network", "file" + */ +type RelatedEventCategory = string; +/** + * A valid type for a related event. E.g. "start", "end", "access" + */ +type RelatedEventType = string; + +/** + * Add/replace related event info (on origin node) for any mock ResolverTree + * + * @param treeToAddRelatedEventsTo the ResolverTree to modify + * @param relatedEventsToAddByCategoryAndType Iterable of `[category, type]` pairs describing related events. e.g. [['dns','info'],['registry','access']] + */ +export function withRelatedEventsOnOrigin( + treeToAddRelatedEventsTo: ResolverTree, + relatedEventsToAddByCategoryAndType: Iterable<[RelatedEventCategory, RelatedEventType]> +): ResolverTree { + const events = []; + const byCategory: Record = {}; + const stats = { + totalAlerts: 0, + events: { + total: 0, + byCategory, + }, + }; + for (const [category, type] of relatedEventsToAddByCategoryAndType) { + events.push( + mockRelatedEvent({ + entityID: treeToAddRelatedEventsTo.entityID, + timestamp: 1, + category, + type, + }) + ); + stats.events.total++; + stats.events.byCategory[category] = stats.events.byCategory[category] + ? stats.events.byCategory[category] + 1 + : 1; + } + return { + ...treeToAddRelatedEventsTo, + stats, + relatedEvents: { + events, + nextEvent: null, + }, + }; +} + export function mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, 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 2a2354921a3d4..ed30643ed871e 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 @@ -220,6 +220,28 @@ export class Simulator { ); } + /** + * Dump all contents of the outer ReactWrapper (to be `console.log`ged as appropriate) + * This will include both DOM (div, span, etc.) and React/JSX (MyComponent, MyGrid, etc.) + */ + public debugWrapper() { + return this.wrapper.debug(); + } + + /** + * Return an Enzyme ReactWrapper that includes the Related Events host button for a given process node + * + * @param entityID The entity ID of the proocess node to select in + */ + public processNodeRelatedEventButton(entityID: string): ReactWrapper { + return this.processNodeElements({ entityID }).findWhere( + (wrapper) => + // Filter out React components + typeof wrapper.type() === 'string' && + wrapper.prop('data-test-subj') === 'resolver:submenu:button' + ); + } + /** * Return the selected node query string values. */ 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 f339d128944cc..c819491dd28f0 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 @@ -9,14 +9,14 @@ import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher import '../test_utilities/extend_jest'; -describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { - let simulator: Simulator; - let databaseDocumentID: string; - let entityIDs: { origin: string; firstChild: string; secondChild: string }; +let simulator: Simulator; +let databaseDocumentID: string; +let entityIDs: { origin: string; firstChild: string; secondChild: string }; - // 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'; +// 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 that has 1 ancestor and 2 children', () => { beforeEach(async () => { // create a mock data access layer const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren(); @@ -79,6 +79,7 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( simulator .processNodeElements({ entityID: entityIDs.secondChild }) .find('button') + .first() .simulate('click'); }); it('should render the second child node as selected, and the first child not as not selected, and the query string should indicate that the second child is selected', async () => { @@ -107,3 +108,52 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( }); }); }); + +describe('Resolver, when analyzing a tree that has some related events', () => { + beforeEach(async () => { + // create a mock data access layer with related events + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren({ + withRelatedEvents: [ + ['registry', 'access'], + ['registry', 'access'], + ], + }); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + describe('when it has loaded', () => { + beforeEach(async () => { + await expect( + simulator.mapStateTransitions(() => ({ + graphElements: simulator.graphElement().length, + graphLoadingElements: simulator.graphLoadingElement().length, + graphErrorElements: simulator.graphErrorElement().length, + originNode: simulator.processNodeElements({ entityID: entityIDs.origin }).length, + })) + ).toYieldEqualTo({ + graphElements: 1, + graphLoadingElements: 0, + graphErrorElements: 0, + originNode: 1, + }); + }); + + it('should render a related events button', async () => { + await expect( + simulator.mapStateTransitions(() => ({ + relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length, + })) + ).toYieldEqualTo({ + relatedEventButtons: 1, + }); + }); + }); +}); 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 6a9ab184e9bab..7f0ba244146fd 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -233,6 +233,7 @@ const NodeSubMenuComponents = React.memo( iconType={menuIsOpen ? 'arrowUp' : 'arrowDown'} iconSide="right" tabIndex={-1} + data-test-subj="resolver:submenu:button" > {count ? : ''} {menuTitle}