Skip to content
Merged
7 changes: 6 additions & 1 deletion x-pack/plugins/enterprise_search/server/__mocks__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@
*/

export { MockRouter } from './router.mock';
export { mockConfig, mockLogger, mockDependencies } from './routerDependencies.mock';
export {
mockConfig,
mockLogger,
mockRequestHandler,
mockDependencies,
} from './routerDependencies.mock';
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { ConfigType } from '../';

export const mockLogger = loggingSystemMock.createLogger().get();

export const mockRequestHandler = {
createRequest: jest.fn(() => () => {}),
hasValidData(data: any) {
return (this.createRequest as jest.Mock).mock.calls[0][0].hasValidData(data);
},
};

export const mockConfig = {
enabled: true,
host: 'http://localhost:3002',
Expand All @@ -24,4 +31,5 @@ export const mockDependencies = {
// Mock router should be handled on a per-test basis
config: mockConfig,
log: mockLogger,
enterpriseSearchRequestHandler: mockRequestHandler as any,
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,24 @@

import { mockConfig, mockLogger } from '../__mocks__';

import { createEnterpriseSearchRequestHandler } from './enterprise_search_request_handler';
import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler';

jest.mock('node-fetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fetchMock = require('node-fetch') as jest.Mock;
const { Response } = jest.requireActual('node-fetch');

const responseMock = {
ok: jest.fn(),
custom: jest.fn(),
customError: jest.fn(),
};
const KibanaAuthHeader = 'Basic 123';

describe('createEnterpriseSearchRequestHandler', () => {
describe('EnterpriseSearchRequestHandler', () => {
const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
}) as any;

beforeEach(() => {
jest.clearAllMocks();
fetchMock.mockReset();
Expand All @@ -33,9 +37,7 @@ describe('createEnterpriseSearchRequestHandler', () => {

EnterpriseSearchAPI.mockReturn(responseBody);

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/as/credentials/collection',
});

Expand All @@ -47,82 +49,146 @@ describe('createEnterpriseSearchRequestHandler', () => {
});

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1'
'http://localhost:3002/as/credentials/collection?type=indexed&pageIndex=1',
{ method: 'GET' }
);

expect(responseMock.ok).toHaveBeenCalledWith({
expect(responseMock.custom).toHaveBeenCalledWith({
body: responseBody,
statusCode: 200,
});
});

describe('when an API request fails', () => {
it('should return 502 with a message', async () => {
EnterpriseSearchAPI.mockReturnError();
describe('request passing', () => {
it('passes route method', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' });

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
await makeAPICall(requestHandler, { route: { method: 'POST' } });
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', {
method: 'POST',
});

await makeAPICall(requestHandler);
await makeAPICall(requestHandler, { route: { method: 'DELETE' } });
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', {
method: 'DELETE',
});
});

it('passes request body', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' });
await makeAPICall(requestHandler, { body: { bodacious: true } });

EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example', {
body: '{"bodacious":true}',
});
});

it('passes custom params set by the handler, which override request params', async () => {
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/example',
params: '?some=custom&params=true',
});
await makeAPICall(requestHandler, { query: { overriden: true } });

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection'
'http://localhost:3002/api/example?some=custom&params=true'
);
});
});

expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting or fetching data from Enterprise Search',
statusCode: 502,
});
describe('response passing', () => {
it('returns the response status code from Enterprise Search', async () => {
EnterpriseSearchAPI.mockReturn({}, { status: 404 });

const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/example' });
await makeAPICall(requestHandler);

EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example');
expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 404 });
});

// TODO: It's possible we may also pass back headers at some point
// from Enterprise Search, e.g. the x-read-only mode header
});

describe('when `hasValidData` fails', () => {
it('should return 502 with a message', async () => {
const responseBody = {
foo: 'bar',
};
describe('error handling', () => {
afterEach(() => {
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('Error connecting to Enterprise Search')
);
});

it('returns an error when an API request fails', async () => {
EnterpriseSearchAPI.mockReturnError();
const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/failed' });

await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/failed');

EnterpriseSearchAPI.mockReturn(responseBody);
expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting to Enterprise Search: Failed',
statusCode: 502,
});
});

const requestHandler = createEnterpriseSearchRequestHandler({
config: mockConfig,
log: mockLogger,
path: '/as/credentials/collection',
hasValidData: (body?: any) =>
Array.isArray(body?.results) && typeof body?.meta?.page?.total_results === 'number',
it('returns an error when `hasValidData` fails', async () => {
EnterpriseSearchAPI.mockReturn({ results: false });
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/invalid',
hasValidData: (body?: any) => Array.isArray(body?.results),
});

await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/invalid');

EnterpriseSearchAPI.shouldHaveBeenCalledWith(
'http://localhost:3002/as/credentials/collection'
expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting to Enterprise Search: Invalid data received',
statusCode: 502,
});
expect(mockLogger.debug).toHaveBeenCalledWith(
'Invalid data received from <http://localhost:3002/api/invalid>: {"results":false}'
);
});

it('returns an error when user authentication to Enterprise Search fails', async () => {
EnterpriseSearchAPI.mockReturn({}, { url: 'http://localhost:3002/login' });
const requestHandler = enterpriseSearchRequestHandler.createRequest({
path: '/api/unauthenticated',
});

await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/unauthenticated');

expect(responseMock.customError).toHaveBeenCalledWith({
body: 'Error connecting or fetching data from Enterprise Search',
body: 'Error connecting to Enterprise Search: Cannot authenticate Enterprise Search user',
statusCode: 502,
});
});
});
});

const makeAPICall = (handler: Function, params = {}) => {
const request = { headers: { authorization: KibanaAuthHeader }, ...params };
const request = {
headers: { authorization: 'Basic 123' },
route: { method: 'GET' },
body: {},
...params,
};
return handler(null, request, responseMock);
};

const EnterpriseSearchAPI = {
shouldHaveBeenCalledWith(expectedUrl: string, expectedParams = {}) {
expect(fetchMock).toHaveBeenCalledWith(expectedUrl, {
headers: { Authorization: KibanaAuthHeader },
headers: { Authorization: 'Basic 123' },
method: 'GET',
body: undefined,
...expectedParams,
});
},
mockReturn(response: object) {
mockReturn(response: object, options?: object) {
fetchMock.mockImplementation(() => {
return Promise.resolve(new Response(JSON.stringify(response)));
return Promise.resolve(new Response(JSON.stringify(response), options));
});
},
mockReturnError() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,63 +7,90 @@
import fetch from 'node-fetch';
import querystring from 'querystring';
import {
RequestHandler,
RequestHandlerContext,
KibanaRequest,
KibanaResponseFactory,
Logger,
} from 'src/core/server';
import { ConfigType } from '../index';

interface IEnterpriseSearchRequestParams<ResponseBody> {
interface IConstructorDependencies {
config: ConfigType;
log: Logger;
}
interface IRequestParams<ResponseBody> {
path: string;
params?: string;
hasValidData?: (body?: ResponseBody) => boolean;
}
export interface IEnterpriseSearchRequestHandler {
createRequest(requestParams?: object): RequestHandler<unknown, Readonly<{}>, unknown>;
}

/**
* This helper function creates a single standard DRY way of handling
* This helper lib creates a single standard DRY way of handling
* Enterprise Search API requests.
*
* This handler assumes that it will essentially just proxy the
* Enterprise Search API request, so the request body and request
* parameters are simply passed through.
*/
export function createEnterpriseSearchRequestHandler<ResponseBody>({
config,
log,
path,
hasValidData = () => true,
}: IEnterpriseSearchRequestParams<ResponseBody>) {
return async (
_context: RequestHandlerContext,
request: KibanaRequest<unknown, Readonly<{}>, unknown>,
response: KibanaResponseFactory
) => {
try {
const enterpriseSearchUrl = config.host as string;
const params = request.query ? `?${querystring.stringify(request.query)}` : '';
const url = `${encodeURI(enterpriseSearchUrl)}${path}${params}`;
export class EnterpriseSearchRequestHandler {
private enterpriseSearchUrl: string;
private log: Logger;

const apiResponse = await fetch(url, {
headers: { Authorization: request.headers.authorization as string },
});
constructor({ config, log }: IConstructorDependencies) {
this.log = log;
this.enterpriseSearchUrl = config.host as string;
}

const body = await apiResponse.json();
createRequest<ResponseBody>({
path,
params,
hasValidData = () => true,
}: IRequestParams<ResponseBody>) {
return async (
_context: RequestHandlerContext,
request: KibanaRequest<unknown, Readonly<{}>, unknown>,
response: KibanaResponseFactory
) => {
try {
// Set up API URL
params = params ?? (request.query ? `?${querystring.stringify(request.query)}` : '');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could params be an object rather than a string? This would let us "merge" params, if some need to be dynamic and some static.

const allParams = {
  ...(params && { params }), // base params
  ...(request.query && { request.query }) // override / mix in dynamic params
}
params = (Object.keys(allParams).length > 0 ? `?${querystring.stringify(allParams)}` : '');

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! Great call, thanks!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

f6a709d - this is so much better, thanks for the amazing suggestion!!

I also realized while testing it that request.query is also an empty obj so in reality our request.query ? check was never failing 🤦‍♀️

const url = encodeURI(this.enterpriseSearchUrl + path + params);

if (hasValidData(body)) {
return response.ok({ body });
} else {
throw new Error(`Invalid data received: ${JSON.stringify(body)}`);
}
} catch (e) {
log.error(`Cannot connect to Enterprise Search: ${e.toString()}`);
if (e instanceof Error) log.debug(e.stack as string);
// Set up API options
const { method } = request.route;
const headers = { Authorization: request.headers.authorization as string };
const body = Object.keys(request.body as object).length
? JSON.stringify(request.body)
: undefined;
Copy link
Contributor Author

@cee-chen cee-chen Aug 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up to the above comment:

Kibana's request.body sends an empty obj, while node-fetch's expects an undefined if there's no body, hence this required ternary check.

We might also want to consider checking for method === 'GET'/HEAD and not allowing a body for GET requests (see below repeated screenshot), but that's maybe overkill? What do you think?

Screen Shot 2020-08-26 at 4 53 40 PM


// Call the Enterprise Search API and pass back response to the front-end
const apiResponse = await fetch(url, { method, headers, body });

if (apiResponse.url.endsWith('/login')) {
throw new Error('Cannot authenticate Enterprise Search user');
}

return response.customError({
statusCode: 502,
body: 'Error connecting or fetching data from Enterprise Search',
});
}
};
const { status } = apiResponse;
const json = await apiResponse.json();

if (hasValidData(json)) {
return response.custom({ statusCode: status, body: json });
} else {
this.log.debug(`Invalid data received from <${url}>: ${JSON.stringify(json)}`);
throw new Error('Invalid data received');
}
} catch (e) {
const errorMessage = `Error connecting to Enterprise Search: ${e?.message || e.toString()}`;

this.log.error(errorMessage);
if (e instanceof Error) this.log.debug(e.stack as string);

return response.customError({ statusCode: 502, body: errorMessage });
Copy link
Contributor Author

@cee-chen cee-chen Aug 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😁 I was accidentally able to test the new functionality where we send custom error messages back to the console (instead of the previous generic "Error connecting" message) when implementing the body payload:

Screen Shot 2020-08-26 at 4 53 40 PM

VERY useful for debugging, and I'm happy w/ this change - the only thing to keep an eye on is if we start catching errors that leak sensitive info in the message somehow.

}
};
}
}
Loading