[Enterprise Search] Add read-only mode interceptor and error handler (#77569) (#77782)

* Add readOnlyMode prop + callout to Layout component

* Update HttpLogic to initialize readOnlyMode from config_data

+ update App Search & Workplace Search layout to pass readOnlyMode state
- update passed props test to not refer to readOnlyMode, so as not to confuse distinction between props.readOnlyMode (passed on init, can grow stale) and HttpLogic.values.readOnlyMode (will update on every http call)

- DRY out HttpLogic initializeHttp type defs

* Update enterpriseSearchRequestHandler to pass read-only mode header

+ add a custom 503 API response for read-only mode errors that come back from API endpoints (e.g. when attempting to create/edit a document) - this is so we correctly display a flash message error instead of the generic "Error Connecting" state

+ note that we still need to send back read only mode on ALL headers, not just on handleReadOnlyModeError however - this is so that the read-only mode state can updates dynamically on all API polls (e.g. on a 200 GET)

* Add HttpLogic read-only mode interceptor

- which should now dynamically listen / update state every time an Enterprise Search API call is made

+ DRY out isEnterpriseSearchApi helper and making wrapping/branching clearer

* PR feedback: Copy
This commit is contained in:
Constance 2020-09-17 10:23:40 -07:00 committed by GitHub
parent 456760b894
commit 11458060da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 298 additions and 59 deletions

View file

@ -76,4 +76,6 @@ export const JSON_HEADER = {
Accept: 'application/json', // Required for Enterprise Search APIs
};
export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode';
export const ENGINES_PAGE_SIZE = 10;

View file

@ -54,6 +54,7 @@ describe('AppSearchConfigured', () => {
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(Layout)).toHaveLength(1);
expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy();
expect(wrapper.find(EngineOverview)).toHaveLength(1);
});
@ -61,9 +62,9 @@ describe('AppSearchConfigured', () => {
const initializeAppData = jest.fn();
(useActions as jest.Mock).mockImplementation(() => ({ initializeAppData }));
shallow(<AppSearchConfigured readOnlyMode={true} />);
shallow(<AppSearchConfigured ilmEnabled={true} />);
expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true });
expect(initializeAppData).toHaveBeenCalledWith({ ilmEnabled: true });
});
it('does not re-initialize app data', () => {
@ -83,6 +84,14 @@ describe('AppSearchConfigured', () => {
expect(wrapper.find(ErrorConnecting)).toHaveLength(1);
});
it('passes readOnlyMode state', () => {
(useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true }));
const wrapper = shallow(<AppSearchConfigured />);
expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true);
});
});
describe('AppSearchNav', () => {

View file

@ -51,7 +51,7 @@ export const AppSearchUnconfigured: React.FC = () => (
export const AppSearchConfigured: React.FC<IInitialAppData> = (props) => {
const { hasInitialized } = useValues(AppLogic);
const { initializeAppData } = useActions(AppLogic);
const { errorConnecting } = useValues(HttpLogic);
const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
if (!hasInitialized) initializeAppData(props);
@ -63,7 +63,7 @@ export const AppSearchConfigured: React.FC<IInitialAppData> = (props) => {
<SetupGuide />
</Route>
<Route>
<Layout navigation={<AppSearchNav />}>
<Layout navigation={<AppSearchNav />} readOnlyMode={readOnlyMode}>
{errorConnecting ? (
<ErrorConnecting />
) : (

View file

@ -69,7 +69,11 @@ export const renderApp = (
>
<LicenseProvider license$={plugins.licensing.license$}>
<Provider store={store}>
<HttpProvider http={core.http} errorConnecting={errorConnecting} />
<HttpProvider
http={core.http}
errorConnecting={errorConnecting}
readOnlyMode={initialData.readOnlyMode}
/>
<FlashMessagesProvider history={params.history} />
<Router history={params.history}>
<App {...initialData} />

View file

@ -16,6 +16,7 @@ describe('HttpLogic', () => {
http: null,
httpInterceptors: [],
errorConnecting: false,
readOnlyMode: false,
};
beforeEach(() => {
@ -31,12 +32,17 @@ describe('HttpLogic', () => {
describe('initializeHttp()', () => {
it('sets values based on passed props', () => {
HttpLogic.mount();
HttpLogic.actions.initializeHttp({ http: mockHttp, errorConnecting: true });
HttpLogic.actions.initializeHttp({
http: mockHttp,
errorConnecting: true,
readOnlyMode: true,
});
expect(HttpLogic.values).toEqual({
http: mockHttp,
httpInterceptors: [],
errorConnecting: true,
readOnlyMode: true,
});
});
});
@ -52,50 +58,110 @@ describe('HttpLogic', () => {
});
});
describe('setReadOnlyMode()', () => {
it('sets readOnlyMode value', () => {
HttpLogic.mount();
HttpLogic.actions.setReadOnlyMode(true);
expect(HttpLogic.values.readOnlyMode).toEqual(true);
HttpLogic.actions.setReadOnlyMode(false);
expect(HttpLogic.values.readOnlyMode).toEqual(false);
});
});
describe('http interceptors', () => {
describe('initializeHttpInterceptors()', () => {
beforeEach(() => {
HttpLogic.mount();
jest.spyOn(HttpLogic.actions, 'setHttpInterceptors');
jest.spyOn(HttpLogic.actions, 'setErrorConnecting');
HttpLogic.actions.initializeHttp({ http: mockHttp });
HttpLogic.actions.initializeHttpInterceptors();
});
it('calls http.intercept and sets an array of interceptors', () => {
mockHttp.intercept.mockImplementationOnce(() => 'removeInterceptorFn' as any);
mockHttp.intercept
.mockImplementationOnce(() => 'removeErrorInterceptorFn' as any)
.mockImplementationOnce(() => 'removeReadOnlyInterceptorFn' as any);
HttpLogic.actions.initializeHttpInterceptors();
expect(mockHttp.intercept).toHaveBeenCalled();
expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith(['removeInterceptorFn']);
expect(HttpLogic.actions.setHttpInterceptors).toHaveBeenCalledWith([
'removeErrorInterceptorFn',
'removeReadOnlyInterceptorFn',
]);
});
describe('errorConnectingInterceptor', () => {
let interceptedResponse: any;
beforeEach(() => {
interceptedResponse = mockHttp.intercept.mock.calls[0][0].responseError;
jest.spyOn(HttpLogic.actions, 'setErrorConnecting');
});
it('handles errors connecting to Enterprise Search', async () => {
const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
const httpResponse = { response: { url: '/api/app_search/engines', status: 502 } };
await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
const httpResponse = {
response: { url: '/api/app_search/engines', status: 502 },
};
await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).toHaveBeenCalled();
});
it('does not handle non-502 Enterprise Search errors', async () => {
const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
const httpResponse = { response: { url: '/api/workplace_search/overview', status: 404 } };
await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
const httpResponse = {
response: { url: '/api/workplace_search/overview', status: 404 },
};
await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled();
});
it('does not handle errors for unrelated calls', async () => {
const { responseError } = mockHttp.intercept.mock.calls[0][0] as any;
const httpResponse = { response: { url: '/api/some_other_plugin/', status: 502 } };
await expect(responseError(httpResponse)).rejects.toEqual(httpResponse);
it('does not handle errors for non-Enterprise Search API calls', async () => {
const httpResponse = {
response: { url: '/api/some_other_plugin/', status: 502 },
};
await expect(interceptedResponse(httpResponse)).rejects.toEqual(httpResponse);
expect(HttpLogic.actions.setErrorConnecting).not.toHaveBeenCalled();
});
});
describe('readOnlyModeInterceptor', () => {
let interceptedResponse: any;
beforeEach(() => {
interceptedResponse = mockHttp.intercept.mock.calls[1][0].response;
jest.spyOn(HttpLogic.actions, 'setReadOnlyMode');
});
it('sets readOnlyMode to true if the response header is true', async () => {
const httpResponse = {
response: { url: '/api/app_search/engines', headers: { get: () => 'true' } },
};
await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(true);
});
it('sets readOnlyMode to false if the response header is false', async () => {
const httpResponse = {
response: { url: '/api/workplace_search/overview', headers: { get: () => 'false' } },
};
await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
expect(HttpLogic.actions.setReadOnlyMode).toHaveBeenCalledWith(false);
});
it('does not handle headers for non-Enterprise Search API calls', async () => {
const httpResponse = {
response: { url: '/api/some_other_plugin/', headers: { get: () => 'true' } },
};
await expect(interceptedResponse(httpResponse)).resolves.toEqual(httpResponse);
expect(HttpLogic.actions.setReadOnlyMode).not.toHaveBeenCalled();
});
});
});
it('sets httpInterceptors and calls all valid remove functions on unmount', () => {

View file

@ -6,32 +6,32 @@
import { kea, MakeLogicType } from 'kea';
import { HttpSetup, HttpInterceptorResponseError } from 'src/core/public';
import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public';
import { IHttpProviderProps } from './http_provider';
import { READ_ONLY_MODE_HEADER } from '../../../../common/constants';
export interface IHttpValues {
http: HttpSetup;
httpInterceptors: Function[];
errorConnecting: boolean;
readOnlyMode: boolean;
}
export interface IHttpActions {
initializeHttp({
http,
errorConnecting,
}: {
http: HttpSetup;
errorConnecting?: boolean;
}): { http: HttpSetup; errorConnecting?: boolean };
initializeHttp({ http, errorConnecting, readOnlyMode }: IHttpProviderProps): IHttpProviderProps;
initializeHttpInterceptors(): void;
setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] };
setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean };
setReadOnlyMode(readOnlyMode: boolean): { readOnlyMode: boolean };
}
export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
actions: {
initializeHttp: ({ http, errorConnecting }) => ({ http, errorConnecting }),
initializeHttp: (props) => props,
initializeHttpInterceptors: () => null,
setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }),
setErrorConnecting: (errorConnecting) => ({ errorConnecting }),
setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }),
},
reducers: {
http: [
@ -53,6 +53,13 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
setErrorConnecting: (_, { errorConnecting }) => errorConnecting,
},
],
readOnlyMode: [
false,
{
initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode,
setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode,
},
],
},
listeners: ({ values, actions }) => ({
initializeHttpInterceptors: () => {
@ -60,13 +67,13 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
const errorConnectingInterceptor = values.http.intercept({
responseError: async (httpResponse) => {
const { url, status } = httpResponse.response!;
const hasErrorConnecting = status === 502;
const isApiResponse =
url.includes('/api/app_search/') || url.includes('/api/workplace_search/');
if (isEnterpriseSearchApi(httpResponse)) {
const { status } = httpResponse.response!;
const hasErrorConnecting = status === 502;
if (isApiResponse && hasErrorConnecting) {
actions.setErrorConnecting(true);
if (hasErrorConnecting) {
actions.setErrorConnecting(true);
}
}
// Re-throw error so that downstream catches work as expected
@ -75,7 +82,23 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
});
httpInterceptors.push(errorConnectingInterceptor);
// TODO: Read only mode interceptor
const readOnlyModeInterceptor = values.http.intercept({
response: async (httpResponse) => {
if (isEnterpriseSearchApi(httpResponse)) {
const readOnlyMode = httpResponse.response!.headers.get(READ_ONLY_MODE_HEADER);
if (readOnlyMode === 'true') {
actions.setReadOnlyMode(true);
} else {
actions.setReadOnlyMode(false);
}
}
return Promise.resolve(httpResponse);
},
});
httpInterceptors.push(readOnlyModeInterceptor);
actions.setHttpInterceptors(httpInterceptors);
},
}),
@ -87,3 +110,11 @@ export const HttpLogic = kea<MakeLogicType<IHttpValues, IHttpActions>>({
},
}),
});
/**
* Small helper that checks whether or not an http call is for an Enterprise Search API
*/
const isEnterpriseSearchApi = (httpResponse: HttpResponse) => {
const { url } = httpResponse.response!;
return url.includes('/api/app_search/') || url.includes('/api/workplace_search/');
};

View file

@ -17,6 +17,7 @@ describe('HttpProvider', () => {
const props = {
http: {} as any,
errorConnecting: false,
readOnlyMode: false,
};
const initializeHttp = jest.fn();
const initializeHttpInterceptors = jest.fn();

View file

@ -11,9 +11,10 @@ import { HttpSetup } from 'src/core/public';
import { HttpLogic } from './http_logic';
interface IHttpProviderProps {
export interface IHttpProviderProps {
http: HttpSetup;
errorConnecting?: boolean;
readOnlyMode?: boolean;
}
export const HttpProvider: React.FC<IHttpProviderProps> = (props) => {

View file

@ -81,4 +81,15 @@
padding: $euiSize;
}
}
&__readOnlyMode {
margin: -$euiSizeM 0 $euiSizeL;
@include euiBreakpoint('m') {
margin: 0 0 $euiSizeL;
}
@include euiBreakpoint('xs', 's') {
margin: 0;
}
}
}

View file

@ -6,7 +6,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { EuiPageSideBar, EuiButton, EuiPageBody } from '@elastic/eui';
import { EuiPageSideBar, EuiButton, EuiPageBody, EuiCallOut } from '@elastic/eui';
import { Layout, INavContext } from './layout';
@ -55,6 +55,12 @@ describe('Layout', () => {
expect(wrapper.find(EuiPageSideBar).prop('className')).not.toContain('--isOpen');
});
it('renders a read-only mode callout', () => {
const wrapper = shallow(<Layout navigation={null} readOnlyMode={true} />);
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
});
it('renders children', () => {
const wrapper = shallow(
<Layout navigation={null}>

View file

@ -7,7 +7,7 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton } from '@elastic/eui';
import { EuiPage, EuiPageSideBar, EuiPageBody, EuiButton, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import './layout.scss';
@ -15,6 +15,7 @@ import './layout.scss';
interface ILayoutProps {
navigation: React.ReactNode;
restrictWidth?: boolean;
readOnlyMode?: boolean;
}
export interface INavContext {
@ -22,7 +23,12 @@ export interface INavContext {
}
export const NavContext = React.createContext({});
export const Layout: React.FC<ILayoutProps> = ({ children, navigation, restrictWidth }) => {
export const Layout: React.FC<ILayoutProps> = ({
children,
navigation,
restrictWidth,
readOnlyMode,
}) => {
const [isNavOpen, setIsNavOpen] = useState(false);
const toggleNavigation = () => setIsNavOpen(!isNavOpen);
const closeNavigation = () => setIsNavOpen(false);
@ -56,6 +62,17 @@ export const Layout: React.FC<ILayoutProps> = ({ children, navigation, restrictW
<NavContext.Provider value={{ closeNavigation }}>{navigation}</NavContext.Provider>
</EuiPageSideBar>
<EuiPageBody className="enterpriseSearchLayout__body" restrictWidth={restrictWidth}>
{readOnlyMode && (
<EuiCallOut
className="enterpriseSearchLayout__readOnlyMode"
color="warning"
iconType="lock"
title={i18n.translate('xpack.enterpriseSearch.readOnlyMode.warning', {
defaultMessage:
'Enterprise Search is in read-only mode. You will be unable to make changes such as creating, editing, or deleting.',
})}
/>
)}
{children}
</EuiPageBody>
</EuiPage>

View file

@ -12,6 +12,7 @@ import { Redirect } from 'react-router-dom';
import { shallow } from 'enzyme';
import { useValues, useActions } from 'kea';
import { Layout } from '../shared/layout';
import { SetupGuide } from './views/setup_guide';
import { ErrorState } from './views/error_state';
import { Overview } from './views/overview';
@ -53,6 +54,7 @@ describe('WorkplaceSearchConfigured', () => {
it('renders with layout', () => {
const wrapper = shallow(<WorkplaceSearchConfigured />);
expect(wrapper.find(Layout).prop('readOnlyMode')).toBeFalsy();
expect(wrapper.find(Overview)).toHaveLength(1);
});
@ -60,9 +62,9 @@ describe('WorkplaceSearchConfigured', () => {
const initializeAppData = jest.fn();
(useActions as jest.Mock).mockImplementation(() => ({ initializeAppData }));
shallow(<WorkplaceSearchConfigured readOnlyMode={true} />);
shallow(<WorkplaceSearchConfigured isFederatedAuth={true} />);
expect(initializeAppData).toHaveBeenCalledWith({ readOnlyMode: true });
expect(initializeAppData).toHaveBeenCalledWith({ isFederatedAuth: true });
});
it('does not re-initialize app data', () => {
@ -82,4 +84,12 @@ describe('WorkplaceSearchConfigured', () => {
expect(wrapper.find(ErrorState)).toHaveLength(2);
});
it('passes readOnlyMode state', () => {
(useValues as jest.Mock).mockImplementation(() => ({ readOnlyMode: true }));
const wrapper = shallow(<WorkplaceSearchConfigured />);
expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true);
});
});

View file

@ -31,7 +31,7 @@ export const WorkplaceSearch: React.FC<IInitialAppData> = (props) => {
export const WorkplaceSearchConfigured: React.FC<IInitialAppData> = (props) => {
const { hasInitialized } = useValues(AppLogic);
const { initializeAppData } = useActions(AppLogic);
const { errorConnecting } = useValues(HttpLogic);
const { errorConnecting, readOnlyMode } = useValues(HttpLogic);
useEffect(() => {
if (!hasInitialized) initializeAppData(props);
@ -46,7 +46,7 @@ export const WorkplaceSearchConfigured: React.FC<IInitialAppData> = (props) => {
{errorConnecting ? <ErrorState /> : <Overview />}
</Route>
<Route>
<Layout navigation={<WorkplaceSearchNav />}>
<Layout navigation={<WorkplaceSearchNav />} readOnlyMode={readOnlyMode}>
{errorConnecting ? (
<ErrorState />
) : (

View file

@ -5,7 +5,7 @@
*/
import { mockConfig, mockLogger } from '../__mocks__';
import { JSON_HEADER } from '../../common/constants';
import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
import { EnterpriseSearchRequestHandler } from './enterprise_search_request_handler';
@ -18,6 +18,9 @@ const responseMock = {
custom: jest.fn(),
customError: jest.fn(),
};
const mockExpectedResponseHeaders = {
[READ_ONLY_MODE_HEADER]: 'false',
};
describe('EnterpriseSearchRequestHandler', () => {
const enterpriseSearchRequestHandler = new EnterpriseSearchRequestHandler({
@ -58,6 +61,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.custom).toHaveBeenCalledWith({
body: responseBody,
statusCode: 200,
headers: mockExpectedResponseHeaders,
});
});
@ -112,11 +116,12 @@ describe('EnterpriseSearchRequestHandler', () => {
await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/example');
expect(responseMock.custom).toHaveBeenCalledWith({ body: {}, statusCode: 201 });
expect(responseMock.custom).toHaveBeenCalledWith({
body: {},
statusCode: 201,
headers: mockExpectedResponseHeaders,
});
});
// TODO: It's possible we may also pass back headers at some point
// from Enterprise Search, e.g. the x-read-only mode header
});
});
@ -140,6 +145,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'some error message',
attributes: { errors: ['some error message'] },
},
headers: mockExpectedResponseHeaders,
});
});
@ -156,6 +162,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'one,two,three',
attributes: { errors: ['one', 'two', 'three'] },
},
headers: mockExpectedResponseHeaders,
});
});
@ -171,6 +178,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Bad Request',
attributes: { errors: ['Bad Request'] },
},
headers: mockExpectedResponseHeaders,
});
});
@ -186,6 +194,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Bad Request',
attributes: { errors: ['Bad Request'] },
},
headers: mockExpectedResponseHeaders,
});
});
@ -201,6 +210,7 @@ describe('EnterpriseSearchRequestHandler', () => {
message: 'Not Found',
attributes: { errors: ['Not Found'] },
},
headers: mockExpectedResponseHeaders,
});
});
});
@ -215,12 +225,33 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: expect.stringContaining('Enterprise Search encountered an internal server error'),
headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Enterprise Search Server Error 500 at <http://localhost:3002/api/5xx>: "something crashed!"'
);
});
it('handleReadOnlyModeError()', async () => {
EnterpriseSearchAPI.mockReturn(
{ errors: ['Read only mode'] },
{ status: 503, headers: { ...JSON_HEADER, [READ_ONLY_MODE_HEADER]: 'true' } }
);
const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/api/503' });
await makeAPICall(requestHandler);
EnterpriseSearchAPI.shouldHaveBeenCalledWith('http://localhost:3002/api/503');
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 503,
body: expect.stringContaining('Enterprise Search is in read-only mode'),
headers: { [READ_ONLY_MODE_HEADER]: 'true' },
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Cannot perform action: Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.'
);
});
it('handleInvalidDataError()', async () => {
EnterpriseSearchAPI.mockReturn({ results: false });
const requestHandler = enterpriseSearchRequestHandler.createRequest({
@ -234,6 +265,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Invalid data received from Enterprise Search',
headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalledWith(
'Invalid data received from <http://localhost:3002/api/invalid>: {"results":false}'
@ -250,6 +282,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Error connecting to Enterprise Search: Failed',
headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalled();
});
@ -265,6 +298,7 @@ describe('EnterpriseSearchRequestHandler', () => {
expect(responseMock.customError).toHaveBeenCalledWith({
statusCode: 502,
body: 'Cannot authenticate Enterprise Search user',
headers: mockExpectedResponseHeaders,
});
expect(mockLogger.error).toHaveBeenCalled();
});
@ -279,6 +313,18 @@ describe('EnterpriseSearchRequestHandler', () => {
});
});
it('setResponseHeaders', async () => {
EnterpriseSearchAPI.mockReturn('anything' as any, {
headers: { [READ_ONLY_MODE_HEADER]: 'true' },
});
const requestHandler = enterpriseSearchRequestHandler.createRequest({ path: '/' });
await makeAPICall(requestHandler);
expect(enterpriseSearchRequestHandler.headers).toEqual({
[READ_ONLY_MODE_HEADER]: 'true',
});
});
it('isEmptyObj', async () => {
expect(enterpriseSearchRequestHandler.isEmptyObj({})).toEqual(true);
expect(enterpriseSearchRequestHandler.isEmptyObj({ empty: false })).toEqual(false);
@ -304,9 +350,10 @@ const EnterpriseSearchAPI = {
...expectedParams,
});
},
mockReturn(response: object, options?: object) {
mockReturn(response: object, options?: any) {
fetchMock.mockImplementation(() => {
return Promise.resolve(new Response(JSON.stringify(response), options));
const headers = Object.assign({}, mockExpectedResponseHeaders, options?.headers);
return Promise.resolve(new Response(JSON.stringify(response), { ...options, headers }));
});
},
mockReturnError() {

View file

@ -14,7 +14,7 @@ import {
Logger,
} from 'src/core/server';
import { ConfigType } from '../index';
import { JSON_HEADER } from '../../common/constants';
import { JSON_HEADER, READ_ONLY_MODE_HEADER } from '../../common/constants';
interface IConstructorDependencies {
config: ConfigType;
@ -46,6 +46,7 @@ export interface IEnterpriseSearchRequestHandler {
export class EnterpriseSearchRequestHandler {
private enterpriseSearchUrl: string;
private log: Logger;
private headers: Record<string, string> = {};
constructor({ config, log }: IConstructorDependencies) {
this.log = log;
@ -80,6 +81,9 @@ export class EnterpriseSearchRequestHandler {
// Call the Enterprise Search API
const apiResponse = await fetch(url, { method, headers, body });
// Handle response headers
this.setResponseHeaders(apiResponse);
// Handle authentication redirects
if (apiResponse.url.endsWith('/login') || apiResponse.url.endsWith('/ent/select')) {
return this.handleAuthenticationError(response);
@ -88,7 +92,13 @@ export class EnterpriseSearchRequestHandler {
// Handle 400-500+ responses from the Enterprise Search server
const { status } = apiResponse;
if (status >= 500) {
return this.handleServerError(response, apiResponse, url);
if (this.headers[READ_ONLY_MODE_HEADER] === 'true') {
// Handle 503 read-only mode errors
return this.handleReadOnlyModeError(response);
} else {
// Handle unexpected server errors
return this.handleServerError(response, apiResponse, url);
}
} else if (status >= 400) {
return this.handleClientError(response, apiResponse);
}
@ -100,7 +110,11 @@ export class EnterpriseSearchRequestHandler {
}
// Pass successful responses back to the front-end
return response.custom({ statusCode: status, body: json });
return response.custom({
statusCode: status,
headers: this.headers,
body: json,
});
} catch (e) {
// Catch connection/auth errors
return this.handleConnectionError(response, e);
@ -160,7 +174,7 @@ export class EnterpriseSearchRequestHandler {
const { status } = apiResponse;
const body = await this.getErrorResponseBody(apiResponse);
return response.customError({ statusCode: status, body });
return response.customError({ statusCode: status, headers: this.headers, body });
}
async handleServerError(response: KibanaResponseFactory, apiResponse: Response, url: string) {
@ -172,14 +186,22 @@ export class EnterpriseSearchRequestHandler {
'Enterprise Search encountered an internal server error. Please contact your system administrator if the problem persists.';
this.log.error(`Enterprise Search Server Error ${status} at <${url}>: ${message}`);
return response.customError({ statusCode: 502, body: errorMessage });
return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
handleReadOnlyModeError(response: KibanaResponseFactory) {
const errorMessage =
'Enterprise Search is in read-only mode. Actions that create, update, or delete information are disabled.';
this.log.error(`Cannot perform action: ${errorMessage}`);
return response.customError({ statusCode: 503, headers: this.headers, body: errorMessage });
}
handleInvalidDataError(response: KibanaResponseFactory, url: string, json: object) {
const errorMessage = 'Invalid data received from Enterprise Search';
this.log.error(`Invalid data received from <${url}>: ${JSON.stringify(json)}`);
return response.customError({ statusCode: 502, body: errorMessage });
return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
handleConnectionError(response: KibanaResponseFactory, e: Error) {
@ -188,14 +210,26 @@ export class EnterpriseSearchRequestHandler {
this.log.error(errorMessage);
if (e instanceof Error) this.log.debug(e.stack as string);
return response.customError({ statusCode: 502, body: errorMessage });
return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
handleAuthenticationError(response: KibanaResponseFactory) {
const errorMessage = 'Cannot authenticate Enterprise Search user';
this.log.error(errorMessage);
return response.customError({ statusCode: 502, body: errorMessage });
return response.customError({ statusCode: 502, headers: this.headers, body: errorMessage });
}
/**
* Set response headers
*
* Currently just forwards the read-only mode header, but we can expand this
* in the future to pass more headers from Enterprise Search as we need them
*/
setResponseHeaders(apiResponse: Response) {
const readOnlyMode = apiResponse.headers.get(READ_ONLY_MODE_HEADER);
this.headers[READ_ONLY_MODE_HEADER] = readOnlyMode as 'true' | 'false';
}
/**