[Endpoint] new AppRootProvider + Policy details tests (#62319)

* Refactor of mocks into own dir ++ added `createAppContextTestRender`
* new AppRootProvider component
* Refactor application `index.tsx` to use `AppRootProvider`
* Add `generatePolicyDatasource()` to EndpointDocGenerator
* Test for policy details
This commit is contained in:
Paul Tavares 2020-04-07 16:37:15 -04:00 committed by GitHub
parent 5e1708f884
commit eacdbcd4f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 454 additions and 58 deletions

View file

@ -7,6 +7,11 @@
import uuid from 'uuid';
import seedrandom from 'seedrandom';
import { AlertEvent, EndpointEvent, HostMetadata, OSFields, HostFields } from './types';
// FIXME: move types/model to top-level
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { PolicyData } from '../public/applications/endpoint/types';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { generatePolicy } from '../public/applications/endpoint/models/policy';
export type Event = AlertEvent | EndpointEvent;
@ -452,6 +457,39 @@ export class EndpointDocGenerator {
}
}
/**
* Generates an Ingest `datasource` that includes the Endpoint Policy data
*/
public generatePolicyDatasource(): PolicyData {
return {
id: this.seededUUIDv4(),
name: 'Endpoint Policy',
description: 'Policy to protect the worlds data',
config_id: this.seededUUIDv4(),
enabled: true,
output_id: '',
inputs: [
{
type: 'endpoint',
enabled: true,
streams: [],
config: {
policy: {
value: generatePolicy(),
},
},
},
],
namespace: 'default',
package: {
name: 'endpoint',
title: 'Elastic Endpoint',
version: '1.0.0',
},
revision: 1,
};
}
private randomN(n: number): number {
return Math.floor(this.random() * n);
}

View file

@ -7,13 +7,9 @@
import * as React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart, AppMountParameters, ScopedHistory } from 'kibana/public';
import { I18nProvider, FormattedMessage } from '@kbn/i18n/react';
import { Route, Switch, Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n/react';
import { Route, Switch } from 'react-router-dom';
import { Store } from 'redux';
import { useObservable } from 'react-use';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { RouteCapture } from './view/route_capture';
import { EndpointPluginStartDependencies } from '../../plugin';
import { appStoreFactory } from './store';
import { AlertIndex } from './view/alerts';
@ -21,7 +17,7 @@ import { HostList } from './view/hosts';
import { PolicyList } from './view/policy';
import { PolicyDetails } from './view/policy';
import { HeaderNavigation } from './components/header_nav';
import { EuiThemeProvider } from '../../../../../legacy/common/eui_styled_components';
import { AppRootProvider } from './view/app_root_provider';
/**
* This module will be loaded asynchronously to reduce the bundle size of your plugin's main bundle.
@ -49,54 +45,31 @@ interface RouterProps {
}
const AppRoot: React.FunctionComponent<RouterProps> = React.memo(
({
history,
store,
coreStart: { http, notifications, uiSettings, application },
depsStart: { data },
}) => {
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
({ history, store, coreStart, depsStart }) => {
return (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={{ http, notifications, application, data }}>
<EuiThemeProvider darkMode={isDarkMode}>
<Router history={history}>
<RouteCapture>
<HeaderNavigation />
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage
id="xpack.endpoint.welcomeTitle"
defaultMessage="Hello World"
/>
</h1>
)}
/>
<Route path="/hosts" component={HostList} />
<Route path="/alerts" component={AlertIndex} />
<Route path="/policy" exact component={PolicyList} />
<Route path="/policy/:id" exact component={PolicyDetails} />
<Route
render={() => (
<FormattedMessage
id="xpack.endpoint.notFound"
defaultMessage="Page Not Found"
/>
)}
/>
</Switch>
</RouteCapture>
</Router>
</EuiThemeProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
<HeaderNavigation />
<Switch>
<Route
exact
path="/"
render={() => (
<h1 data-test-subj="welcomeTitle">
<FormattedMessage id="xpack.endpoint.welcomeTitle" defaultMessage="Hello World" />
</h1>
)}
/>
<Route path="/hosts" component={HostList} />
<Route path="/alerts" component={AlertIndex} />
<Route path="/policy" exact component={PolicyList} />
<Route path="/policy/:id" exact component={PolicyDetails} />
<Route
render={() => (
<FormattedMessage id="xpack.endpoint.notFound" defaultMessage="Page Not Found" />
)}
/>
</Switch>
</AppRootProvider>
);
}
);

View file

@ -0,0 +1,70 @@
/*
* 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 React from 'react';
import { createMemoryHistory } from 'history';
import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
import { appStoreFactory } from '../store';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { EndpointPluginStartDependencies } from '../../../plugin';
import { depsStartMock } from './dependencies_start_mock';
import { AppRootProvider } from '../view/app_root_provider';
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
/**
* Mocked app root context renderer
*/
interface AppContextTestRender {
store: ReturnType<typeof appStoreFactory>;
history: ReturnType<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
depsStart: EndpointPluginStartDependencies;
/**
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
* `AppRootContext`
*/
AppWrapper: React.FC<any>;
/**
* Renders the given UI within the created `AppWrapper` providing the given UI a mocked
* endpoint runtime context environment
*/
render: UiRender;
}
/**
* Creates a mocked endpoint app context custom renderer that can be used to render
* component that depend upon the application's surrounding context providers.
* Factory also returns the content that was used to create the custom renderer, allowing
* for further customization.
*/
export const createAppRootMockRenderer = (): AppContextTestRender => {
const history = createMemoryHistory<never>();
const coreStart = coreMock.createStart({ basePath: '/mock' });
const depsStart = depsStartMock();
const store = appStoreFactory({ coreStart, depsStart });
const AppWrapper: React.FunctionComponent<{ children: React.ReactElement }> = ({ children }) => (
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
{children}
</AppRootProvider>
);
const render: UiRender = (ui, options) => {
// @ts-ignore
return reactRender(ui, {
wrapper: AppWrapper,
...options,
});
};
return {
store,
history,
coreStart,
depsStart,
AppWrapper,
render,
};
};

View file

@ -7,7 +7,7 @@
import {
dataPluginMock,
Start as DataPublicStartMock,
} from '../../../../../../src/plugins/data/public/mocks';
} from '../../../../../../../src/plugins/data/public/mocks';
type DataMock = Omit<DataPublicStartMock, 'indexPatterns' | 'query'> & {
indexPatterns: Omit<DataPublicStartMock['indexPatterns'], 'getFieldsForWildcard'> & {

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './dependencies_start_mock';
export * from './app_context_render';

View file

@ -74,8 +74,12 @@ export const policyDetailsReducer: Reducer<PolicyDetailsState, AppAction> = (
...state,
location: action.payload,
};
const isCurrentlyOnDetailsPage = isOnPolicyDetailsPage(newState);
const wasPreviouslyOnDetailsPage = isOnPolicyDetailsPage(state);
if (isOnPolicyDetailsPage(newState)) {
// Did user just enter the Detail page? if so, then set the loading indicator and return new state
if (isCurrentlyOnDetailsPage && !wasPreviouslyOnDetailsPage) {
newState.isLoading = true;
return newState;
}
return {

View file

@ -0,0 +1,58 @@
/*
* 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 React, { memo, ReactNode, useMemo } from 'react';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n/react';
import { Router } from 'react-router-dom';
import { History } from 'history';
import { CoreStart } from 'kibana/public';
import { useObservable } from 'react-use';
import { EuiThemeProvider } from '../../../../../../legacy/common/eui_styled_components';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
import { appStoreFactory } from '../store';
import { RouteCapture } from './route_capture';
import { EndpointPluginStartDependencies } from '../../../plugin';
/**
* Provides the context for rendering the endpoint app
*/
export const AppRootProvider = memo<{
store: ReturnType<typeof appStoreFactory>;
history: History;
coreStart: CoreStart;
depsStart: EndpointPluginStartDependencies;
children: ReactNode | ReactNode[];
}>(
({
store,
history,
coreStart: { http, notifications, uiSettings, application },
depsStart: { data },
children,
}) => {
const isDarkMode = useObservable<boolean>(uiSettings.get$('theme:darkMode'));
const services = useMemo(() => ({ http, notifications, application, data }), [
application,
data,
http,
notifications,
]);
return (
<Provider store={store}>
<I18nProvider>
<KibanaContextProvider services={services}>
<EuiThemeProvider darkMode={isDarkMode}>
<Router history={history}>
<RouteCapture>{children}</RouteCapture>
</Router>
</EuiThemeProvider>
</KibanaContextProvider>
</I18nProvider>
</Provider>
);
}
);

View file

@ -61,7 +61,7 @@ export const AgentsSummary = memo<AgentsSummaryProps>(props => {
}, []);
return (
<EuiFlexGroup gutterSize="xl">
<EuiFlexGroup gutterSize="xl" data-test-subj="policyAgentsSummary">
{stats.map(({ key, title, health }) => {
return (
<EuiFlexItem grow={false} key={key}>

View file

@ -0,0 +1,239 @@
/*
* 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 React from 'react';
import { mount } from 'enzyme';
import { createAppRootMockRenderer } from '../../mocks';
import { PolicyDetails } from './policy_details';
import { EndpointDocGenerator } from '../../../../../common/generate_data';
describe('Policy Details', () => {
type FindReactWrapperResponse = ReturnType<ReturnType<typeof render>['find']>;
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
const generator = new EndpointDocGenerator();
const { history, AppWrapper, coreStart } = createAppRootMockRenderer();
const http = coreStart.http;
const render = (ui: Parameters<typeof mount>[0]) => mount(ui, { wrappingComponent: AppWrapper });
let policyDatasource: ReturnType<typeof generator.generatePolicyDatasource>;
let policyView: ReturnType<typeof render>;
beforeEach(() => jest.clearAllMocks());
afterEach(() => {
if (policyView) {
policyView.unmount();
}
});
describe('when displayed with invalid id', () => {
beforeEach(() => {
http.get.mockReturnValue(Promise.reject(new Error('policy not found')));
history.push('/policy/1');
policyView = render(<PolicyDetails />);
});
it('should show loader followed by error message', () => {
expect(policyView.find('EuiLoadingSpinner').length).toBe(1);
policyView.update();
const callout = policyView.find('EuiCallOut');
expect(callout).toHaveLength(1);
expect(callout.prop('color')).toEqual('danger');
expect(callout.text()).toEqual('policy not found');
});
});
describe('when displayed with valid id', () => {
let asyncActions: Promise<unknown> = Promise.resolve();
beforeEach(() => {
policyDatasource = generator.generatePolicyDatasource();
policyDatasource.id = '1';
http.get.mockImplementation((...args) => {
const [path] = args;
if (typeof path === 'string') {
// GET datasouce
if (path === '/api/ingest_manager/datasources/1') {
asyncActions = asyncActions.then<unknown>(async (): Promise<unknown> => await sleep());
return Promise.resolve({
item: policyDatasource,
success: true,
});
}
// GET Agent status for agent config
if (path === '/api/ingest_manager/fleet/agent-status') {
asyncActions = asyncActions.then(async () => await sleep());
return Promise.resolve({
results: { events: 0, total: 5, online: 3, error: 1, offline: 1 },
success: true,
});
}
}
return Promise.reject(new Error('unknown API call!'));
});
history.push('/policy/1');
policyView = render(<PolicyDetails />);
});
it('should display back to list button and policy title', () => {
policyView.update();
const pageHeaderLeft = policyView.find(
'EuiPageHeaderSection[data-test-subj="pageViewHeaderLeft"]'
);
const backToListButton = pageHeaderLeft.find('EuiButtonEmpty');
expect(backToListButton.prop('iconType')).toBe('arrowLeft');
expect(backToListButton.prop('href')).toBe('/mock/app/endpoint/policy');
expect(backToListButton.text()).toBe('Back to policy list');
const pageTitle = pageHeaderLeft.find('[data-test-subj="pageViewHeaderLeftTitle"]');
expect(pageTitle).toHaveLength(1);
expect(pageTitle.text()).toEqual(policyDatasource.name);
});
it('should navigate to list if back to link is clicked', async () => {
policyView.update();
const backToListButton = policyView.find(
'EuiPageHeaderSection[data-test-subj="pageViewHeaderLeft"] EuiButtonEmpty'
);
expect(history.location.pathname).toEqual('/policy/1');
backToListButton.simulate('click');
expect(history.location.pathname).toEqual('/policy');
});
it('should display agent stats', async () => {
await asyncActions;
policyView.update();
const headerRight = policyView.find(
'EuiPageHeaderSection[data-test-subj="pageViewHeaderRight"]'
);
const agentsSummary = headerRight.find('EuiFlexGroup[data-test-subj="policyAgentsSummary"]');
expect(agentsSummary).toHaveLength(1);
expect(agentsSummary.text()).toBe('Hosts5Online3Offline1Error1');
});
it('should display cancel button', async () => {
await asyncActions;
policyView.update();
const cancelbutton = policyView.find(
'EuiButtonEmpty[data-test-subj="policyDetailsCancelButton"]'
);
expect(cancelbutton).toHaveLength(1);
expect(cancelbutton.text()).toEqual('Cancel');
});
it('should redirect to policy list when cancel button is clicked', async () => {
await asyncActions;
policyView.update();
const cancelbutton = policyView.find(
'EuiButtonEmpty[data-test-subj="policyDetailsCancelButton"]'
);
expect(history.location.pathname).toEqual('/policy/1');
cancelbutton.simulate('click');
expect(history.location.pathname).toEqual('/policy');
});
it('should display save button', async () => {
await asyncActions;
policyView.update();
const saveButton = policyView.find('EuiButton[data-test-subj="policyDetailsSaveButton"]');
expect(saveButton).toHaveLength(1);
expect(saveButton.text()).toEqual('Save');
});
describe('when the save button is clicked', () => {
let saveButton: FindReactWrapperResponse;
let confirmModal: FindReactWrapperResponse;
let modalCancelButton: FindReactWrapperResponse;
let modalConfirmButton: FindReactWrapperResponse;
beforeEach(async () => {
await asyncActions;
policyView.update();
saveButton = policyView.find('EuiButton[data-test-subj="policyDetailsSaveButton"]');
saveButton.simulate('click');
policyView.update();
confirmModal = policyView.find(
'EuiConfirmModal[data-test-subj="policyDetailsConfirmModal"]'
);
modalCancelButton = confirmModal.find('button[data-test-subj="confirmModalCancelButton"]');
modalConfirmButton = confirmModal.find(
'button[data-test-subj="confirmModalConfirmButton"]'
);
http.put.mockImplementation((...args) => {
asyncActions = asyncActions.then(async () => await sleep());
const [path] = args;
if (typeof path === 'string') {
if (path === '/api/ingest_manager/datasources/1') {
return Promise.resolve({
item: policyDatasource,
success: true,
});
}
}
return Promise.reject(new Error('unknown PUT path!'));
});
});
it('should show a modal confirmation', () => {
expect(confirmModal).toHaveLength(1);
expect(confirmModal.find('div[data-test-subj="confirmModalTitleText"]').text()).toEqual(
'Save and deploy changes'
);
expect(modalCancelButton.text()).toEqual('Cancel');
expect(modalConfirmButton.text()).toEqual('Save and deploy changes');
});
it('should show info callout if policy is in use', () => {
const warningCallout = confirmModal.find(
'EuiCallOut[data-test-subj="policyDetailsWarningCallout"]'
);
expect(warningCallout).toHaveLength(1);
expect(warningCallout.text()).toEqual(
'This action will update 5 hostsSaving these changes will apply the updates to all active endpoints assigned to this policy'
);
});
it('should close dialog if cancel button is clicked', () => {
modalCancelButton.simulate('click');
expect(
policyView.find('EuiConfirmModal[data-test-subj="policyDetailsConfirmModal"]')
).toHaveLength(0);
});
it('should update policy and show success notification when confirm button is clicked', async () => {
modalConfirmButton.simulate('click');
policyView.update();
// Modal should be closed
expect(
policyView.find('EuiConfirmModal[data-test-subj="policyDetailsConfirmModal"]')
).toHaveLength(0);
// API should be called
await asyncActions;
expect(http.put.mock.calls[0][0]).toEqual(`/api/ingest_manager/datasources/1`);
policyView.update();
// Toast notification should be shown
const toastAddMock = coreStart.notifications.toasts.add.mock;
expect(toastAddMock.calls).toHaveLength(1);
expect(toastAddMock.calls[0][0]).toMatchObject({
color: 'success',
iconType: 'check',
});
});
it('should show an error notification toast if update fails', async () => {
policyDatasource.id = 'invalid';
modalConfirmButton.simulate('click');
await asyncActions;
policyView.update();
// Toast notification should be shown
const toastAddMock = coreStart.notifications.toasts.add.mock;
expect(toastAddMock.calls).toHaveLength(1);
expect(toastAddMock.calls[0][0]).toMatchObject({
color: 'danger',
iconType: 'alert',
});
});
});
});
});

View file

@ -149,7 +149,10 @@ export const PolicyDetails = React.memo(() => {
<VerticalDivider spacing="l" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={handleBackToListOnClick}>
<EuiButtonEmpty
onClick={handleBackToListOnClick}
data-test-subj="policyDetailsCancelButton"
>
<FormattedMessage id="xpack.endpoint.policy.details.cancel" defaultMessage="Cancel" />
</EuiButtonEmpty>
</EuiFlexItem>
@ -157,6 +160,7 @@ export const PolicyDetails = React.memo(() => {
<EuiButton
fill={true}
iconType="save"
data-test-subj="policyDetailsSaveButton"
// FIXME: need to disable if User has no write permissions to ingest - see: https://github.com/elastic/endpoint-app-team/issues/296
onClick={handleSaveOnClick}
isLoading={isPolicyLoading}
@ -216,6 +220,7 @@ const ConfirmUpdate = React.memo<{
return (
<EuiOverlayMask>
<EuiConfirmModal
data-test-subj="policyDetailsConfirmModal"
title={i18n.translate('xpack.endpoint.policy.details.updateConfirm.title', {
defaultMessage: 'Save and deploy changes',
})}
@ -237,6 +242,7 @@ const ConfirmUpdate = React.memo<{
{hostCount > 0 && (
<>
<EuiCallOut
data-test-subj="policyDetailsWarningCallout"
title={i18n.translate('xpack.endpoint.policy.details.updateConfirm.warningTitle', {
defaultMessage:
'This action will update {hostCount, plural, one {# host} other {# hosts}}',