Skip to content

Commit

Permalink
Task/hostlist pagination (#63722)
Browse files Browse the repository at this point in the history
* hostlist pagination for endpoint security
  • Loading branch information
parkiino committed Apr 24, 2020
1 parent 69b68a9 commit 54243c8
Show file tree
Hide file tree
Showing 15 changed files with 417 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { HostListPagination, ServerApiError } from '../../types';
import { ServerApiError } from '../../types';
import { HostResultList, HostInfo } from '../../../../../common/types';

interface ServerReturnedHostList {
type: 'serverReturnedHostList';
payload: HostResultList;
}

interface ServerFailedToReturnHostList {
type: 'serverFailedToReturnHostList';
payload: ServerApiError;
}

interface ServerReturnedHostDetails {
type: 'serverReturnedHostDetails';
payload: HostInfo;
Expand All @@ -22,13 +27,8 @@ interface ServerFailedToReturnHostDetails {
payload: ServerApiError;
}

interface UserPaginatedHostList {
type: 'userPaginatedHostList';
payload: HostListPagination;
}

export type HostAction =
| ServerReturnedHostList
| ServerFailedToReturnHostList
| ServerReturnedHostDetails
| ServerFailedToReturnHostDetails
| UserPaginatedHostList;
| ServerFailedToReturnHostDetails;
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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 { CoreStart, HttpSetup } from 'kibana/public';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { AppAction, HostState, HostIndexUIQueryParams } from '../../types';
import { Immutable, HostResultList } from '../../../../../common/types';
import { History, createBrowserHistory } from 'history';
import { hostMiddlewareFactory } from './middleware';
import { applyMiddleware, Store, createStore } from 'redux';
import { hostListReducer } from './reducer';
import { coreMock } from 'src/core/public/mocks';
import { urlFromQueryParams } from '../../view/hosts/url_from_query_params';
import { uiQueryParams } from './selectors';
import { mockHostResultList } from './mock_host_result_list';
import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../test_utils';

describe('host list pagination: ', () => {
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
let history: History<never>;
let store: Store<Immutable<HostState>, Immutable<AppAction>>;
let queryParams: () => HostIndexUIQueryParams;
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
let actionSpyMiddleware;
const getEndpointListApiResponse = (): HostResultList => {
return mockHostResultList({ request_page_size: 1, request_page_index: 1, total: 10 });
};

let historyPush: (params: HostIndexUIQueryParams) => void;
beforeEach(() => {
fakeCoreStart = coreMock.createStart();
depsStart = depsStartMock();
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
history = createBrowserHistory();
const middleware = hostMiddlewareFactory(fakeCoreStart, depsStart);
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>());
store = createStore(hostListReducer, applyMiddleware(middleware, actionSpyMiddleware));

history.listen(location => {
store.dispatch({ type: 'userChangedUrl', payload: location });
});

queryParams = () => uiQueryParams(store.getState());

historyPush = (nextQueryParams: HostIndexUIQueryParams): void => {
return history.push(urlFromQueryParams(nextQueryParams));
};
});

describe('when the user enteres the host list for the first time', () => {
it('the api is called with page_index and page_size defaulting to 0 and 10 respectively', async () => {
const apiResponse = getEndpointListApiResponse();
fakeHttpServices.post.mockResolvedValue(apiResponse);
expect(fakeHttpServices.post).not.toHaveBeenCalled();

store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/hosts',
},
});
await waitForAction('serverReturnedHostList');
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: '0' }, { page_size: '10' }],
}),
});
});
});
describe('when a new page size is passed', () => {
it('should modify the url correctly', () => {
historyPush({ ...queryParams(), page_size: '20' });
expect(queryParams()).toMatchInlineSnapshot(`
Object {
"page_index": "0",
"page_size": "20",
}
`);
});
});
describe('when an invalid page size is passed', () => {
it('should modify the page size in the url to the default page size', () => {
historyPush({ ...queryParams(), page_size: '1' });
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' });
});
});

describe('when a negative page size is passed', () => {
it('should modify the page size in the url to the default page size', () => {
historyPush({ ...queryParams(), page_size: '-1' });
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' });
});
});

describe('when a new page index is passed', () => {
it('should modify the page index in the url correctly', () => {
historyPush({ ...queryParams(), page_index: '2' });
expect(queryParams()).toEqual({ page_index: '2', page_size: '10' });
});
});

describe('when a negative page index is passed', () => {
it('should modify the page index in the url to the default page index', () => {
historyPush({ ...queryParams(), page_index: '-2' });
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' });
});
});

describe('when invalid params are passed in the url', () => {
it('ignores non-numeric values for page_index and page_size', () => {
historyPush({ ...queryParams, page_index: 'one', page_size: 'fifty' });
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' });
});

it('ignores unknown url search params', () => {
store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/hosts',
search: '?foo=bar',
},
});
expect(queryParams()).toEqual({ page_index: '0', page_size: '10' });
});

it('ignores multiple values of the same query params except the last value', () => {
store.dispatch({
type: 'userChangedUrl',
payload: {
...history.location,
pathname: '/hosts',
search: '?page_index=2&page_index=3&page_size=20&page_size=50',
},
});
expect(queryParams()).toEqual({ page_index: '3', page_size: '50' });
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

import { createStore, Dispatch, Store } from 'redux';
import { HostAction, hostListReducer } from './index';
import { HostListState } from '../../types';
import { HostState } from '../../types';
import { listData } from './selectors';
import { mockHostResultList } from './mock_host_result_list';

describe('HostList store concerns', () => {
let store: Store<HostListState>;
let store: Store<HostState>;
let dispatch: Dispatch<HostAction>;
const createTestStore = () => {
store = createStore(hostListReducer);
Expand All @@ -37,6 +37,11 @@ describe('HostList store concerns', () => {
pageIndex: 0,
total: 0,
loading: false,
error: undefined,
details: undefined,
detailsLoading: false,
detailsError: undefined,
location: undefined,
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { History, createBrowserHistory } from 'history';
import { hostListReducer, hostMiddlewareFactory } from './index';
import { HostResultList, Immutable } from '../../../../../common/types';
import { HostListState } from '../../types';
import { HostState } from '../../types';
import { AppAction } from '../action';
import { listData } from './selectors';
import { DepsStartMock, depsStartMock } from '../../mocks';
import { mockHostResultList } from './mock_host_result_list';
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils';

describe('host list middleware', () => {
const sleep = (ms = 100) => new Promise(wakeup => setTimeout(wakeup, ms));
let fakeCoreStart: jest.Mocked<CoreStart>;
let depsStart: DepsStartMock;
let fakeHttpServices: jest.Mocked<HttpSetup>;
type HostListStore = Store<Immutable<HostListState>, Immutable<AppAction>>;
type HostListStore = Store<Immutable<HostState>, Immutable<AppAction>>;
let store: HostListStore;
let getState: HostListStore['getState'];
let dispatch: HostListStore['dispatch'];
let waitForAction: MiddlewareActionSpyHelper['waitForAction'];
let actionSpyMiddleware;

let history: History<never>;
const getEndpointListApiResponse = (): HostResultList => {
Expand All @@ -33,15 +35,16 @@ describe('host list middleware', () => {
fakeCoreStart = coreMock.createStart({ basePath: '/mock' });
depsStart = depsStartMock();
fakeHttpServices = fakeCoreStart.http as jest.Mocked<HttpSetup>;
({ actionSpyMiddleware, waitForAction } = createSpyMiddleware<HostState>());
store = createStore(
hostListReducer,
applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart))
applyMiddleware(hostMiddlewareFactory(fakeCoreStart, depsStart), actionSpyMiddleware)
);
getState = store.getState;
dispatch = store.dispatch;
history = createBrowserHistory();
});
test('handles `userChangedUrl`', async () => {
it('handles `userChangedUrl`', async () => {
const apiResponse = getEndpointListApiResponse();
fakeHttpServices.post.mockResolvedValue(apiResponse);
expect(fakeHttpServices.post).not.toHaveBeenCalled();
Expand All @@ -53,10 +56,10 @@ describe('host list middleware', () => {
pathname: '/hosts',
},
});
await sleep();
await waitForAction('serverReturnedHostList');
expect(fakeHttpServices.post).toHaveBeenCalledWith('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: 0 }, { page_size: 10 }],
paging_properties: [{ page_index: '0' }, { page_size: '10' }],
}),
});
expect(listData(getState())).toEqual(apiResponse.hosts.map(hostInfo => hostInfo.metadata));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,64 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { HostResultList } from '../../../../../common/types';
import { isOnHostPage, hasSelectedHost, uiQueryParams, listData } from './selectors';
import { HostState } from '../../types';
import { ImmutableMiddlewareFactory } from '../../types';
import { pageIndex, pageSize, isOnHostPage, hasSelectedHost, uiQueryParams } from './selectors';
import { HostListState } from '../../types';

export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostListState> = coreStart => {
export const hostMiddlewareFactory: ImmutableMiddlewareFactory<HostState> = coreStart => {
return ({ getState, dispatch }) => next => async action => {
next(action);
const state = getState();
if (
(action.type === 'userChangedUrl' &&
isOnHostPage(state) &&
hasSelectedHost(state) !== true) ||
action.type === 'userPaginatedHostList'
action.type === 'userChangedUrl' &&
isOnHostPage(state) &&
hasSelectedHost(state) !== true
) {
const hostPageIndex = pageIndex(state);
const hostPageSize = pageSize(state);
const response = await coreStart.http.post('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: hostPageIndex }, { page_size: hostPageSize }],
}),
});
response.request_page_index = hostPageIndex;
dispatch({
type: 'serverReturnedHostList',
payload: response,
});
const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state);
try {
const response = await coreStart.http.post<HostResultList>('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }],
}),
});
response.request_page_index = Number(pageIndex);
dispatch({
type: 'serverReturnedHostList',
payload: response,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnHostList',
payload: error,
});
}
}
if (action.type === 'userChangedUrl' && hasSelectedHost(state) !== false) {
// If user navigated directly to a host details page, load the host list
if (listData(state).length === 0) {
const { page_index: pageIndex, page_size: pageSize } = uiQueryParams(state);
try {
const response = await coreStart.http.post('/api/endpoint/metadata', {
body: JSON.stringify({
paging_properties: [{ page_index: pageIndex }, { page_size: pageSize }],
}),
});
response.request_page_index = Number(pageIndex);
dispatch({
type: 'serverReturnedHostList',
payload: response,
});
} catch (error) {
dispatch({
type: 'serverFailedToReturnHostList',
payload: error,
});
return;
}
}

// call the host details api
const { selected_host: selectedHost } = uiQueryParams(state);
try {
const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`);
Expand Down
Loading

0 comments on commit 54243c8

Please sign in to comment.