Skip to content

Commit

Permalink
[Resolver] Improve simulator. Add more click-through tests and panel …
Browse files Browse the repository at this point in the history
…tests. (#74601)

### 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.
  • Loading branch information
Robert Austin committed Aug 11, 2020
1 parent 51443b7 commit e2e7672
Show file tree
Hide file tree
Showing 17 changed files with 336 additions and 271 deletions.
19 changes: 19 additions & 0 deletions x-pack/plugins/security_solution/common/endpoint/models/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,7 +47,7 @@ export function mockEndpointEvent({
executable: 'executable',
args: 'args',
name,
pid: 0,
pid,
hash: {
md5: 'hash.md5',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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<never>;
}) {
this.resolverComponentInstanceID = resolverComponentInstanceID;
// create the spy middleware (for debugging tests)
Expand All @@ -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();
Expand All @@ -95,9 +97,6 @@ export class Simulator {
databaseDocumentID={databaseDocumentID}
/>
);

// Update the enzyme wrapper after each state transition
connectEnzymeWrapperAndStore(this.store, this.wrapper);
}

/**
Expand All @@ -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.
Expand All @@ -124,6 +133,7 @@ export class Simulator {
yield mapper();
await new Promise((resolve) => {
setTimeout(() => {
this.forceAutoSizerOpen();
this.wrapper.update();
resolve();
}, 0);
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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"]');
}

/**
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
14 changes: 5 additions & 9 deletions x-pack/plugins/security_solution/public/resolver/view/assets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
* 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';
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 =
Expand Down Expand Up @@ -141,8 +143,6 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => (
</>
));

PaintServers.displayName = 'PaintServers';

/**
* Ids of symbols to be linked by <use> elements
*/
Expand Down Expand Up @@ -376,8 +376,6 @@ const SymbolsAndShapes = memo(({ isDarkMode }: { isDarkMode: boolean }) => (
</>
));

SymbolsAndShapes.displayName = 'SymbolsAndShapes';

/**
* This `<defs>` element is used to define the reusable assets for the Resolver
* It confers several advantages, including but not limited to:
Expand All @@ -386,7 +384,7 @@ SymbolsAndShapes.displayName = 'SymbolsAndShapes';
* 3. `<use>` elements can be handled by compositor (faster)
*/
const SymbolDefinitionsComponent = memo(({ className }: { className?: string }) => {
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
const isDarkMode = useUiSetting<boolean>(defaultDarkMode);
return (
<svg className={className}>
<defs>
Expand All @@ -397,8 +395,6 @@ const SymbolDefinitionsComponent = memo(({ className }: { className?: string })
);
});

SymbolDefinitionsComponent.displayName = 'SymbolDefinitions';

export const SymbolDefinitions = styled(SymbolDefinitionsComponent)`
position: absolute;
left: 100%;
Expand All @@ -424,7 +420,7 @@ export const useResolverTheme = (): {
nodeAssets: NodeStyleMap;
cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessTrigger: boolean) => NodeStyleConfig;
} => {
const isDarkMode = useUiSetting<boolean>(DEFAULT_DARK_MODE);
const isDarkMode = useUiSetting<boolean>(defaultDarkMode);
const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight;

const getThemedOption = (lightOption: string, darkOption: string): string => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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']);
});
});
});
});
Loading

0 comments on commit e2e7672

Please sign in to comment.