diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 61ce672405fd5..ffde47825b501 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -182,6 +182,15 @@ export interface ResolverRelatedEvents { nextEvent: string | null; } +/** + * Safe version of `ResolverRelatedEvents` + */ +export interface SafeResolverRelatedEvents { + entityID: string; + events: SafeResolverEvent[]; + nextEvent: string | null; +} + /** * Response structure for the alerts route. */ 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/no_ancestors_two_children.ts similarity index 62% rename from x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts rename to x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts index 94c176d343d17..b0407fa5d7c1d 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/no_ancestors_two_children.ts @@ -9,11 +9,8 @@ import { ResolverTree, ResolverEntityIndex, } from '../../../../common/endpoint/types'; -import { mockEndpointEvent } from '../../store/mocks/endpoint_event'; -import { - mockTreeWithNoAncestorsAnd2Children, - withRelatedEventsOnOrigin, -} from '../../store/mocks/resolver_tree'; +import { mockEndpointEvent } from '../../mocks/endpoint_event'; +import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; interface Metadata { @@ -43,24 +40,11 @@ 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( - { withRelatedEvents }: { withRelatedEvents: Iterable<[string, string]> | null } = { - withRelatedEvents: null, - } -): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { +export function noAncestorsTwoChildren(): { 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: { @@ -70,17 +54,13 @@ export function oneAncestorTwoChildren( relatedEvents(entityID: string): Promise { return Promise.resolve({ entityID, - 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, - }), - ], + events: [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], nextEvent: null, }); }, @@ -89,7 +69,13 @@ export function oneAncestorTwoChildren( * Fetch a ResolverTree for a entityID */ resolverTree(): Promise { - return Promise.resolve(composedTree); + return Promise.resolve( + mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }) + ); }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts new file mode 100644 index 0000000000000..01e75e3eefdbf --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -0,0 +1,94 @@ +/* + * 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 { DataAccessLayer } from '../../types'; +import { mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin } from '../../mocks/resolver_tree'; +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + /** + * The entityID of the first child of the origin. + */ + firstChild: 'firstChild'; + /** + * The entityID of the second child of the origin. + */ + secondChild: 'secondChild'; + }; +} + +export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, + }; + const tree = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }); + + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + relatedEvents(entityID: string): Promise { + /** + * Respond with the mocked related events when the origin's related events are fetched. + **/ + const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + + return Promise.resolve({ + entityID, + events, + nextEvent: null, + } as ResolverRelatedEvents); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree(): Promise { + return Promise.resolve(tree); + }, + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns(): string[] { + return ['index pattern']; + }, + + /** + * Get entities matching a document. + */ + entities(): Promise { + return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts similarity index 66% rename from x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts rename to x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts index 709f2faf13b00..c822fdf647c16 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EndpointEvent } from '../../../../common/endpoint/types'; +import { EndpointEvent } from '../../../common/endpoint/types'; /** * Simple mock endpoint event that works for tree layouts. @@ -28,10 +28,29 @@ export function mockEndpointEvent({ type: lifecycleType ? lifecycleType : 'start', category: 'process', }, + agent: { + id: 'agent.id', + version: 'agent.version', + type: 'agent.type', + }, + ecs: { + version: 'ecs.version', + }, + user: { + name: 'user.name', + domain: 'user.domain', + }, process: { entity_id: entityID, + executable: 'executable', + args: 'args', name, + pid: 0, + hash: { + md5: 'hash.md5', + }, parent: { + pid: 0, entity_id: parentEntityId, }, }, diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts similarity index 89% rename from x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts rename to x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index 21d0309501aa8..5d2cbb2eab0dc 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -5,8 +5,7 @@ */ import { mockEndpointEvent } from './endpoint_event'; -import { mockRelatedEvent } from './related_event'; -import { ResolverTree, ResolverEvent } from '../../../../common/endpoint/types'; +import { ResolverTree, ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; export function mockTreeWith2AncestorsAndNoChildren({ originID, @@ -125,11 +124,11 @@ type RelatedEventType = string; * @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( +function withRelatedEventsOnOrigin( treeToAddRelatedEventsTo: ResolverTree, relatedEventsToAddByCategoryAndType: Iterable<[RelatedEventCategory, RelatedEventType]> ): ResolverTree { - const events = []; + const events: SafeResolverEvent[] = []; const byCategory: Record = {}; const stats = { totalAlerts: 0, @@ -139,14 +138,18 @@ export function withRelatedEventsOnOrigin( }, }; for (const [category, type] of relatedEventsToAddByCategoryAndType) { - events.push( - mockRelatedEvent({ - entityID: treeToAddRelatedEventsTo.entityID, - timestamp: 1, - category, + events.push({ + '@timestamp': 1, + event: { + kind: 'event', type, - }) - ); + category, + id: 'xyz', + }, + process: { + entity_id: treeToAddRelatedEventsTo.entityID, + }, + }); stats.events.total++; stats.events.byCategory[category] = stats.events.byCategory[category] ? stats.events.byCategory[category] + 1 @@ -156,7 +159,7 @@ export function withRelatedEventsOnOrigin( ...treeToAddRelatedEventsTo, stats, relatedEvents: { - events, + events: events as ResolverEvent[], nextEvent: null, }, }; @@ -309,3 +312,24 @@ export function mockTreeWithNoProcessEvents(): ResolverTree { }, }; } + +export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ + originID, + firstChildID, + secondChildID, +}: { + originID: string; + firstChildID: string; + secondChildID: string; +}) { + const baseTree = mockTreeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, + }); + const withRelatedEvents: Array<[string, string]> = [ + ['registry', 'access'], + ['registry', 'access'], + ]; + return withRelatedEventsOnOrigin(baseTree, withRelatedEvents); +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 6786a93f1d9ca..15a981d460730 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -15,7 +15,7 @@ import { mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents, mockTreeWithAllProcessesTerminated, mockTreeWithNoProcessEvents, -} from '../mocks/resolver_tree'; +} from '../../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; 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 deleted file mode 100644 index 1e0c460a3a711..0000000000000 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/related_event.ts +++ /dev/null @@ -1,36 +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 { 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/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index dfbc6bd290686..f113e861d3ce9 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -12,7 +12,7 @@ import * as selectors from './selectors'; import { mockTreeWith2AncestorsAndNoChildren, mockTreeWithNoAncestorsAnd2Children, -} from './mocks/resolver_tree'; +} from '../mocks/resolver_tree'; import { SafeResolverEvent } from '../../../common/endpoint/types'; describe('resolver selectors', () => { 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 ed30643ed871e..6f44c5aee7cac 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 @@ -113,83 +113,21 @@ export class Simulator { } /** - * Return a promise that resolves after the `store`'s next state transition. - * Used by `mapStateTransitions` + * Yield the result of `mapper` over and over, once per event-loop cycle. + * After 10 times, quit. + * Use this to continually check a value. See `toYieldEqualTo`. */ - private stateTransitioned(): Promise { - // keep track of the resolve function of the promise that has been returned. - let resolveState: (() => void) | null = null; - - const promise: Promise = new Promise((resolve) => { - // Immediately expose the resolve function in the outer scope. It will be resolved when the next state transition occurs. - resolveState = resolve; - }); - - // Subscribe to the store - const unsubscribe = this.store.subscribe(() => { - // Once a state transition occurs, unsubscribe. - unsubscribe(); - // Resolve the promise. The null assertion is safe here as Promise initializers run immediately (according to spec and node/browser implementations.) - // NB: the state is not resolved here. Code using the simulator should not rely on state or selectors of state. - resolveState!(); - }); - - // Return the promise that will be resolved on the next state transition, allowing code to `await` for the next state transition. - return promise; - } - - /** - * This will yield the return value of `mapper` after each state transition. If no state transition occurs for 10 event loops in a row, this will give up. - */ - public async *mapStateTransitions(mapper: () => R): AsyncIterable { - // Yield the value before any state transitions have occurred. - yield mapper(); - - /** Increment this each time an event loop completes without a state transition. - * If this value hits `10`, end the loop. - * - * Code will test assertions after each state transition. If the assertion hasn't passed and no further state transitions occur, - * then the jest timeout will happen. The timeout doesn't give a useful message about the assertion. - * By short-circuiting this function, code that uses it can short circuit the test timeout and print a useful error message. - * - * NB: the logic to short-circuit the loop is here because knowledge of state is a concern of the simulator, not tests. - */ + public async *map(mapper: () => R): AsyncIterable { let timeoutCount = 0; - while (true) { - /** - * `await` a race between the next state transition and a timeout that happens after `0`ms. - * If the timeout wins, no `dispatch` call caused a state transition in the last loop. - * If this keeps happening, assume that Resolver isn't going to do anything else. - * - * If Resolver adds intentional delay logic (e.g. waiting before making a request), this code might have to change. - * In that case, Resolver should use the side effect context to schedule future work. This code could then subscribe to some event published by the side effect context. That way, this code will be aware of Resolver's intention to do work. - */ - const timedOut: boolean = await Promise.race([ - (async (): Promise => { - await this.stateTransitioned(); - // If a state transition occurs, return false for `timedOut` - return false; - })(), - new Promise((resolve) => { - setTimeout(() => { - // If a timeout occurs, resolve `timedOut` as true - return resolve(true); - }, 0); - }), - ]); - - if (timedOut) { - // If a timout occurred, note it. - timeoutCount++; - if (timeoutCount === 10) { - // if 10 timeouts happen in a row, end the loop early - return; - } - } else { - // If a state transition occurs, reset the timeout count and yield the value - timeoutCount = 0; - yield mapper(); - } + while (timeoutCount < 10) { + timeoutCount++; + yield mapper(); + await new Promise((resolve) => { + setTimeout(() => { + this.wrapper.update(); + resolve(); + }, 0); + }); } } @@ -198,25 +136,22 @@ export class Simulator { * returns a `ReactWrapper` even if nothing is found, as that is how `enzyme` does things. */ public processNodeElements(options: ProcessNodeElementSelectorOptions = {}): ReactWrapper { - return this.findInDOM(processNodeElementSelector(options)); + return this.domNodes(processNodeElementSelector(options)); } /** - * true if a process node element is found for the entityID and if it has an [aria-selected] attribute. + * Return the node element with the given `entityID`. */ - public processNodeElementLooksSelected(entityID: string): boolean { - return this.processNodeElements({ entityID, selected: true }).length === 1; + public selectedProcessNode(entityID: string): ReactWrapper { + return this.processNodeElements({ entityID, selected: true }); } /** - * true if a process node element is found for the entityID and if it *does not have* an [aria-selected] attribute. + * Return the node element with the given `entityID`. It will only be returned if it is not selected. */ - public processNodeElementLooksUnselected(entityID: string): boolean { - // find the process node, then exclude it if its selected. - return ( - this.processNodeElements({ entityID }).not( - processNodeElementSelector({ entityID, selected: true }) - ).length === 1 + public unselectedProcessNode(entityID: string): ReactWrapper { + return this.processNodeElements({ entityID }).not( + processNodeElementSelector({ entityID, selected: true }) ); } @@ -234,11 +169,8 @@ export class Simulator { * @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 this.domNodes( + `${processNodeElementSelector({ entityID })} [data-test-subj="resolver:submenu:button"]` ); } @@ -256,42 +188,98 @@ export class Simulator { * The element that shows when Resolver is waiting for the graph data. */ public graphLoadingElement(): ReactWrapper { - return this.findInDOM('[data-test-subj="resolver:graph:loading"]'); + return this.domNodes('[data-test-subj="resolver:graph:loading"]'); } /** * The element that shows if Resolver couldn't draw the graph. */ public graphErrorElement(): ReactWrapper { - return this.findInDOM('[data-test-subj="resolver:graph:error"]'); + return this.domNodes('[data-test-subj="resolver:graph:error"]'); } /** * The element where nodes get drawn. */ public graphElement(): ReactWrapper { - return this.findInDOM('[data-test-subj="resolver:graph"]'); + return this.domNodes('[data-test-subj="resolver:graph"]'); + } + + /** + * An element with a list of all nodes. + */ + public nodeListElement(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-list"]'); + } + + /** + * Return the items in the node list (the default panel view.) + */ + public nodeListItems(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-list:item"]'); + } + + /** + * The element containing the details for the selected node. + */ + public nodeDetailElement(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail"]'); + } + + /** + * The details of the selected node are shown in a description list. This returns the title elements of the description list. + */ + private nodeDetailEntryTitle(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]'); } /** - * The outer panel container. + * The details of the selected node are shown in a description list. This returns the description elements of the description list. */ - public panelElement(): ReactWrapper { - return this.findInDOM('[data-test-subj="resolver:panel"]'); + private nodeDetailEntryDescription(): ReactWrapper { + return this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]'); } /** - * The panel content element (which may include tables, lists, other data depending on the view). + * Return DOM nodes that match `enzymeSelector`. */ - public panelContentElement(): ReactWrapper { - return this.findInDOM('[data-test-subj^="resolver:panel:"]'); + private domNodes(enzymeSelector: string): ReactWrapper { + return this.wrapper + .find(enzymeSelector) + .filterWhere((wrapper) => typeof wrapper.type() === 'string'); } /** - * Like `this.wrapper.find` but only returns DOM nodes. + * The titles and descriptions (as text) from the node detail panel. */ - private findInDOM(selector: string): ReactWrapper { - return this.wrapper.find(selector).filterWhere((wrapper) => typeof wrapper.type() === 'string'); + public nodeDetailDescriptionListEntries(): Array<[string, string]> { + const titles = this.nodeDetailEntryTitle(); + const descriptions = this.nodeDetailEntryDescription(); + const entries: Array<[string, string]> = []; + for (let index = 0; index < Math.min(titles.length, descriptions.length); index++) { + const title = titles.at(index).text(); + const description = descriptions.at(index).text(); + + // Exclude timestamp since we can't currently calculate the expected description for it from tests + if (title !== '@timestamp') { + entries.push([title, description]); + } + } + return entries; + } + + /** + * Resolve the wrapper returned by `wrapperFactory` only once it has at least 1 element in it. + */ + public async resolveWrapper( + wrapperFactory: () => ReactWrapper, + predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0 + ): Promise { + for await (const wrapper of this.map(wrapperFactory)) { + if (predicate(wrapper)) { + return wrapper; + } + } } } 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 c819491dd28f0..98ea235d3524f 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 @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { oneAncestorTwoChildren } from '../data_access_layer/mocks/one_ancestor_two_children'; +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 { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; let simulator: Simulator; let databaseDocumentID: string; @@ -16,10 +17,10 @@ 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'; -describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { +describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => { beforeEach(async () => { // create a mock data access layer - const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren(); + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren(); // save a reference to the entity IDs exposed by the mock data layer entityIDs = dataAccessLayerMetadata.entityIDs; @@ -40,7 +41,7 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( * * For example, there might be no loading element at one point, and 1 graph element at one point, but never a single time when there is both 1 graph element and 0 loading elements. */ - simulator.mapStateTransitions(() => ({ + simulator.map(() => ({ graphElements: simulator.graphElement().length, graphLoadingElements: simulator.graphLoadingElement().length, graphErrorElements: simulator.graphErrorElement().length, @@ -55,22 +56,23 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( // Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow. it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected.`, async () => { - expect(simulator.processNodeElementLooksSelected(entityIDs.origin)).toBe(true); - - expect(simulator.processNodeElementLooksUnselected(entityIDs.firstChild)).toBe(true); - expect(simulator.processNodeElementLooksUnselected(entityIDs.secondChild)).toBe(true); - - expect(simulator.processNodeElements().length).toBe(3); + await expect( + simulator.map(() => ({ + selectedOriginCount: simulator.selectedProcessNode(entityIDs.origin).length, + unselectedFirstChildCount: simulator.unselectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild).length, + processNodeCount: simulator.processNodeElements().length, + })) + ).toYieldEqualTo({ + selectedOriginCount: 1, + unselectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + processNodeCount: 3, + }); }); - it(`should have the default "process list" panel present`, async () => { - expect(simulator.panelElement().length).toBe(1); - expect(simulator.panelContentElement().length).toBe(1); - const testSubjectName = simulator - .panelContentElement() - .getDOMNode() - .getAttribute('data-test-subj'); - expect(testSubjectName).toMatch(/process-list/g); + it(`should show the node list`, async () => { + await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1); }); describe("when the second child node's first button has been clicked", () => { @@ -82,42 +84,37 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', ( .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 () => { + it('should render the second child node as selected, and the origin as not selected, and the query string should indicate that the second child is selected', async () => { await expect( - simulator.mapStateTransitions(function value() { - return { - // the query string has a key showing that the second child is selected - queryStringSelectedNode: simulator.queryStringValues().selectedNode, - // the second child is rendered in the DOM, and shows up as selected - secondChildLooksSelected: simulator.processNodeElementLooksSelected( - entityIDs.secondChild - ), - // the origin is in the DOM, but shows up as unselected - originLooksUnselected: simulator.processNodeElementLooksUnselected(entityIDs.origin), - }; - }) + simulator.map(() => ({ + // the query string has a key showing that the second child is selected + queryStringSelectedNode: simulator.queryStringValues().selectedNode, + // the second child is rendered in the DOM, and shows up as selected + selectedSecondChildNodeCount: simulator.selectedProcessNode(entityIDs.secondChild) + .length, + // the origin is in the DOM, but shows up as unselected + unselectedOriginNodeCount: simulator.unselectedProcessNode(entityIDs.origin).length, + })) ).toYieldEqualTo({ // Just the second child should be marked as selected in the query string queryStringSelectedNode: [entityIDs.secondChild], // The second child is rendered and has `[aria-selected]` - secondChildLooksSelected: true, + selectedSecondChildNodeCount: 1, // The origin child is rendered and doesn't have `[aria-selected]` - originLooksUnselected: true, + unselectedOriginNodeCount: 1, }); }); }); }); }); -describe('Resolver, when analyzing a tree that has some related events', () => { +describe('Resolver, when analyzing a tree that has two related events for the origin', () => { beforeEach(async () => { // create a mock data access layer with related events - const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren({ - withRelatedEvents: [ - ['registry', 'access'], - ['registry', 'access'], - ], - }); + const { + metadata: dataAccessLayerMetadata, + dataAccessLayer, + } = noAncestorsTwoChildrenWithRelatedEventsOnOrigin(); // save a reference to the entity IDs exposed by the mock data layer entityIDs = dataAccessLayerMetadata.entityIDs; @@ -132,7 +129,7 @@ describe('Resolver, when analyzing a tree that has some related events', () => { describe('when it has loaded', () => { beforeEach(async () => { await expect( - simulator.mapStateTransitions(() => ({ + simulator.map(() => ({ graphElements: simulator.graphElement().length, graphLoadingElements: simulator.graphLoadingElement().length, graphErrorElements: simulator.graphErrorElement().length, @@ -148,7 +145,7 @@ describe('Resolver, when analyzing a tree that has some related events', () => { it('should render a related events button', async () => { await expect( - simulator.mapStateTransitions(() => ({ + simulator.map(() => ({ relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length, })) ).toYieldEqualTo({ 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 new file mode 100644 index 0000000000000..78e5fd79bea13 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { 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'; + +describe('Resolver: when analyzing a tree with no ancestors and two children', () => { + let simulator: Simulator; + let databaseDocumentID: 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'; + + beforeEach(async () => { + // 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; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + it('should show the node list', async () => { + await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1); + }); + + it('should have 3 nodes in the node list', async () => { + await expect(simulator.map(() => simulator.nodeListItems().length)).toYieldEqualTo(3); + }); + describe('when there is an item in the node list and it 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'); + } + }); + it('should show the details for the first node', async () => { + await expect( + simulator.map(() => simulator.nodeDetailDescriptionListEntries()) + ).toYieldEqualTo([ + ['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'], + ]); + }); + }); +}); 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 03d9e4c2d5a2b..112a3400c4947 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 @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, HTMLAttributes } from 'react'; import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from 'react-intl'; +import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import * as selectors from '../../store/selectors'; import * as event from '../../../../common/endpoint/models/event'; import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; @@ -51,9 +52,9 @@ export const ProcessDetails = memo(function ProcessDetails({ const processName = event.eventName(processEvent); const entityId = event.entityId(processEvent); const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); - const processInfoEntry = useMemo(() => { + const processInfoEntry: EuiDescriptionListProps['listItems'] = useMemo(() => { const eventTime = event.eventTimestamp(processEvent); - const dateTime = eventTime ? formatDate(eventTime) : ''; + const dateTime = eventTime === undefined ? null : formatDate(eventTime); const createdEntry = { title: '@timestamp', @@ -95,7 +96,7 @@ export const ProcessDetails = memo(function ProcessDetails({ description: argsForProcess(processEvent), }; - // This is the data in {title, description} form for the EUIDescriptionList to display + // This is the data in {title, description} form for the EuiDescriptionList to display const processDescriptionListData = [ createdEntry, pathEntry, @@ -107,7 +108,7 @@ export const ProcessDetails = memo(function ProcessDetails({ commandLineEntry, ] .filter((entry) => { - return entry.description; + return entry.description !== undefined; }) .map((entry) => { return { @@ -172,13 +173,24 @@ export const ProcessDetails = memo(function ProcessDetails({ + } + descriptionProps={ + { 'data-test-subj': 'resolver:node-detail:entry-description' } as HTMLAttributes< + HTMLElement + > + } compressed listItems={processInfoEntry} /> ); }); -ProcessDetails.displayName = 'ProcessDetails'; 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 046c840470262..11f005f8acbcd 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 @@ -150,7 +150,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map((processEvent) => { - let dateTime; + let dateTime: Date | undefined; const eventTime = event.timestampSafeVersion(processEvent); const name = event.processNameSafeVersion(processEvent); if (eventTime) { @@ -186,13 +186,15 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ const children = useSelector(selectors.hasMoreChildren); const ancestors = useSelector(selectors.hasMoreAncestors); const showWarning = children === true || ancestors === true; + const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( <> {showWarning && } - data-test-subj="resolver:panel:process-list" + rowProps={rowProps} + data-test-subj="resolver:node-list" items={processTableView} columns={columns} sorting @@ -200,4 +202,3 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ ); }); -ProcessListWithCounts.displayName = 'ProcessListWithCounts';