From 11458060dac816ea0bacca2be659e1d20c314efd Mon Sep 17 00:00:00 2001 From: Constance Date: Thu, 17 Sep 2020 10:23:40 -0700 Subject: [PATCH] [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 --- .../enterprise_search/common/constants.ts | 2 + .../applications/app_search/index.test.tsx | 13 ++- .../public/applications/app_search/index.tsx | 4 +- .../public/applications/index.tsx | 6 +- .../shared/http/http_logic.test.ts | 96 ++++++++++++++++--- .../applications/shared/http/http_logic.ts | 63 ++++++++---- .../shared/http/http_provider.test.tsx | 1 + .../shared/http/http_provider.tsx | 3 +- .../applications/shared/layout/layout.scss | 11 +++ .../shared/layout/layout.test.tsx | 8 +- .../applications/shared/layout/layout.tsx | 21 +++- .../workplace_search/index.test.tsx | 14 ++- .../applications/workplace_search/index.tsx | 4 +- .../enterprise_search_request_handler.test.ts | 61 ++++++++++-- .../lib/enterprise_search_request_handler.ts | 50 ++++++++-- 15 files changed, 298 insertions(+), 59 deletions(-) diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index d6a51e8b482d0..5df25f11e5070 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -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; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx index 94e9127bbed74..31c7680fd2f1c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.test.tsx @@ -54,6 +54,7 @@ describe('AppSearchConfigured', () => { const wrapper = shallow(); 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(); + shallow(); - 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(); + + expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + }); }); describe('AppSearchNav', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx index c4a366930d22a..643c4b5ccc873 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/index.tsx @@ -51,7 +51,7 @@ export const AppSearchUnconfigured: React.FC = () => ( export const AppSearchConfigured: React.FC = (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 = (props) => { - }> + } readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/index.tsx b/x-pack/plugins/enterprise_search/public/applications/index.tsx index a54295548004a..82f884644be4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.tsx @@ -69,7 +69,11 @@ export const renderApp = ( > - + diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts index c032e3b04ebe6..b65499be2f7c0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts @@ -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', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts index ec9db30ddef3b..5e2b5a9ed6b06 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts @@ -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>({ 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>({ setErrorConnecting: (_, { errorConnecting }) => errorConnecting, }, ], + readOnlyMode: [ + false, + { + initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode, + setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode, + }, + ], }, listeners: ({ values, actions }) => ({ initializeHttpInterceptors: () => { @@ -60,13 +67,13 @@ export const HttpLogic = kea>({ 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>({ }); 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>({ }, }), }); + +/** + * 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/'); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx index 81106235780d6..902c910f10d7c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx @@ -17,6 +17,7 @@ describe('HttpProvider', () => { const props = { http: {} as any, errorConnecting: false, + readOnlyMode: false, }; const initializeHttp = jest.fn(); const initializeHttpInterceptors = jest.fn(); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx index 4c2160195a1af..db1b0d611079a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx @@ -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 = (props) => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss index f6c83888413d3..e867e9cf5a445 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.scss @@ -81,4 +81,15 @@ padding: $euiSize; } } + + &__readOnlyMode { + margin: -$euiSizeM 0 $euiSizeL; + + @include euiBreakpoint('m') { + margin: 0 0 $euiSizeL; + } + @include euiBreakpoint('xs', 's') { + margin: 0; + } + } } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx index 623e6e47167d2..7b876d81527fa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.test.tsx @@ -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(); + + expect(wrapper.find(EuiCallOut)).toHaveLength(1); + }); + it('renders children', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx index e122c4d5cfdfa..ef8216e8b6711 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/layout/layout.tsx @@ -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 = ({ children, navigation, restrictWidth }) => { +export const Layout: React.FC = ({ + 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 = ({ children, navigation, restrictW {navigation} + {readOnlyMode && ( + + )} {children} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx index 39280ad6f4be4..fc1943264d72b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -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(); + 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(); + shallow(); - 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(); + + expect(wrapper.find(Layout).prop('readOnlyMode')).toEqual(true); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 6a51b49869eaf..a68dfaf8ea471 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -31,7 +31,7 @@ export const WorkplaceSearch: React.FC = (props) => { export const WorkplaceSearchConfigured: React.FC = (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 = (props) => { {errorConnecting ? : } - }> + } readOnlyMode={readOnlyMode}> {errorConnecting ? ( ) : ( diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts index 0c1e81e3aba46..3d0a3181f8ab8 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.test.ts @@ -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 : "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 : {"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() { diff --git a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts index 00d5eaf5d6a83..6b65c16c832fd 100644 --- a/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts +++ b/x-pack/plugins/enterprise_search/server/lib/enterprise_search_request_handler.ts @@ -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 = {}; 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'; } /**