[Workplace Search] Refactor oauth redirect to persist state params from plugin (#90067)

* Add routes

Also adds validation for the Kibana way of handling query params

* Add route for oauth params

* Add logic to save oauth redirect query params

* Refactor source added template to keep all logic in logic file

* Add tests for component and logic

* Add optional param to interface

Atlassian flows may also send back an oauth_verifier param that we’ll need. This was added to the server validation, but I forgot to add it to the interface

* Remove failing test

This was not needed for coverage and it appears that the helper doesn’t validate query params so removing it

* Remove index_permissions from account params

* Rename variable

* Update param syntax

* Update account route test

* Refactor params
This commit is contained in:
Scotty Bollinger 2021-02-03 14:40:12 -06:00 committed by GitHub
parent 1228c53ac6
commit 72fb9ce22b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 319 additions and 89 deletions

View file

@ -282,6 +282,8 @@ export const GITHUB_LINK_TITLE = i18n.translate(
export const CUSTOM_SERVICE_TYPE = 'custom';
export const WORKPLACE_SEARCH_URL_PREFIX = '/app/enterprise_search/workplace_search';
export const DOCUMENTATION_LINK_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.documentation',
{

View file

@ -4,16 +4,24 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { LogicMounter, mockFlashMessageHelpers, mockHttpValues } from '../../../../../__mocks__';
import {
LogicMounter,
mockFlashMessageHelpers,
mockHttpValues,
mockKibanaValues,
} from '../../../../../__mocks__';
import { AppLogic } from '../../../../app_logic';
jest.mock('../../../../app_logic', () => ({
AppLogic: { values: { isOrganization: true } },
}));
import { SourcesLogic } from '../../sources_logic';
import { nextTick } from '@kbn/test/jest';
import { CustomSource } from '../../../../types';
import { SOURCES_PATH, getSourcesPath } from '../../../../routes';
import { sourceConfigData } from '../../../../__mocks__/content_sources.mock';
@ -28,6 +36,7 @@ import {
describe('AddSourceLogic', () => {
const { mount } = new LogicMounter(AddSourceLogic);
const { http } = mockHttpValues;
const { navigateToUrl } = mockKibanaValues;
const { clearFlashMessages, flashAPIErrors } = mockFlashMessageHelpers;
const defaultValues = {
@ -264,6 +273,55 @@ describe('AddSourceLogic', () => {
});
});
describe('saveSourceParams', () => {
const params = {
code: 'code123',
state: '"{"state": "foo"}"',
session_state: 'session123',
};
const queryString =
'code=code123&state=%22%7B%22state%22%3A%20%22foo%22%7D%22&session_state=session123';
const response = { serviceName: 'name', indexPermissions: false, serviceType: 'zendesk' };
beforeEach(() => {
SourcesLogic.mount();
});
it('sends params to server and calls correct methods', async () => {
const setAddedSourceSpy = jest.spyOn(SourcesLogic.actions, 'setAddedSource');
const { serviceName, indexPermissions, serviceType } = response;
http.get.mockReturnValue(Promise.resolve(response));
AddSourceLogic.actions.saveSourceParams(queryString);
expect(http.get).toHaveBeenCalledWith('/api/workplace_search/sources/create', {
query: {
...params,
kibana_host: '',
},
});
await nextTick();
expect(setAddedSourceSpy).toHaveBeenCalledWith(serviceName, indexPermissions, serviceType);
expect(navigateToUrl).toHaveBeenCalledWith(
getSourcesPath(SOURCES_PATH, AppLogic.values.isOrganization)
);
});
it('handles error', async () => {
http.get.mockReturnValue(Promise.reject('this is an error'));
AddSourceLogic.actions.saveSourceParams(queryString);
await nextTick();
expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
expect(navigateToUrl).toHaveBeenCalledWith(
getSourcesPath(SOURCES_PATH, AppLogic.values.isOrganization)
);
});
});
describe('organization context', () => {
describe('getSourceConfigData', () => {
it('calls API and sets values', async () => {
@ -301,22 +359,37 @@ describe('AddSourceLogic', () => {
AddSourceLogic.actions.getSourceConnectData('github', successCallback);
const query = {
index_permissions: false,
kibana_host: '',
};
expect(clearFlashMessages).toHaveBeenCalled();
expect(AddSourceLogic.values.buttonLoading).toEqual(true);
expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/sources/github/prepare');
expect(http.get).toHaveBeenCalledWith(
'/api/workplace_search/org/sources/github/prepare',
{ query }
);
await nextTick();
expect(setSourceConnectDataSpy).toHaveBeenCalledWith(sourceConnectData);
expect(successCallback).toHaveBeenCalledWith(sourceConnectData.oauthUrl);
expect(setButtonNotLoadingSpy).toHaveBeenCalled();
});
it('appends query params', () => {
it('passes query params', () => {
AddSourceLogic.actions.setSourceSubdomainValue('subdomain');
AddSourceLogic.actions.setSourceIndexPermissionsValue(true);
AddSourceLogic.actions.getSourceConnectData('github', successCallback);
const query = {
index_permissions: true,
kibana_host: '',
subdomain: 'subdomain',
};
expect(http.get).toHaveBeenCalledWith(
'/api/workplace_search/org/sources/github/prepare?subdomain=subdomain&index_permissions=true'
'/api/workplace_search/org/sources/github/prepare',
{ query }
);
});
@ -413,7 +486,7 @@ describe('AddSourceLogic', () => {
http.put
).toHaveBeenCalledWith(
`/api/workplace_search/org/settings/connectors/${sourceConfigData.serviceType}`,
{ body: JSON.stringify({ params }) }
{ body: JSON.stringify(params) }
);
await nextTick();
@ -436,7 +509,7 @@ describe('AddSourceLogic', () => {
};
expect(http.post).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors', {
body: JSON.stringify({ params: createParams }),
body: JSON.stringify(createParams),
});
});
@ -515,11 +588,15 @@ describe('AddSourceLogic', () => {
});
it('getSourceConnectData', () => {
const query = {
kibana_host: '',
};
AddSourceLogic.actions.getSourceConnectData('github', jest.fn());
expect(http.get).toHaveBeenCalledWith(
'/api/workplace_search/account/sources/github/prepare'
);
expect(
http.get
).toHaveBeenCalledWith('/api/workplace_search/account/sources/github/prepare', { query });
});
it('getSourceReConnectData', () => {

View file

@ -8,9 +8,15 @@ import { keys, pickBy } from 'lodash';
import { kea, MakeLogicType } from 'kea';
import { Search } from 'history';
import { i18n } from '@kbn/i18n';
import { HttpFetchQuery } from 'src/core/public';
import { HttpLogic } from '../../../../../shared/http';
import { KibanaLogic } from '../../../../../shared/kibana';
import { parseQueryParams } from '../../../../../shared/query_params';
import {
flashAPIErrors,
@ -19,9 +25,11 @@ import {
} from '../../../../../shared/flash_messages';
import { staticSourceData } from '../../source_data';
import { CUSTOM_SERVICE_TYPE } from '../../../../constants';
import { SOURCES_PATH, getSourcesPath } from '../../../../routes';
import { CUSTOM_SERVICE_TYPE, WORKPLACE_SEARCH_URL_PREFIX } from '../../../../constants';
import { AppLogic } from '../../../../app_logic';
import { SourcesLogic } from '../../sources_logic';
import { CustomSource } from '../../../../types';
export interface AddSourceProps {
@ -42,6 +50,13 @@ export enum AddSourceSteps {
ReAuthenticateStep = 'ReAuthenticate',
}
export interface OauthParams {
code: string;
state: string;
session_state: string;
oauth_verifier?: string;
}
export interface AddSourceActions {
initializeAddSource: (addSourceProps: AddSourceProps) => { addSourceProps: AddSourceProps };
setAddSourceProps: ({
@ -75,6 +90,7 @@ export interface AddSourceActions {
isUpdating: boolean,
successCallback?: () => void
): { isUpdating: boolean; successCallback?(): void };
saveSourceParams(search: Search): { search: Search };
getSourceConfigData(serviceType: string): { serviceType: string };
getSourceConnectData(
serviceType: string,
@ -141,6 +157,15 @@ interface PreContentSourceResponse {
githubOrganizations: string[];
}
/**
* Workplace Search needs to know the host for the redirect. As of yet, we do not
* have access to this in Kibana. We parse it from the browser and pass it as a param.
*/
const {
location: { href },
} = window;
const kibanaHost = href.substr(0, href.indexOf(WORKPLACE_SEARCH_URL_PREFIX));
export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceActions>>({
path: ['enterprise_search', 'workplace_search', 'add_source_logic'],
actions: {
@ -173,6 +198,7 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
isUpdating,
successCallback,
}),
saveSourceParams: (search: Search) => ({ search }),
createContentSource: (
serviceType: string,
successCallback: () => void,
@ -356,14 +382,15 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
? `/api/workplace_search/org/sources/${serviceType}/prepare`
: `/api/workplace_search/account/sources/${serviceType}/prepare`;
const params = new URLSearchParams();
if (subdomain) params.append('subdomain', subdomain);
if (indexPermissions) params.append('index_permissions', indexPermissions.toString());
const hasParams = params.has('subdomain') || params.has('index_permissions');
const paramsString = hasParams ? `?${params}` : '';
const query = {
kibana_host: kibanaHost,
} as HttpFetchQuery;
if (isOrganization) query.index_permissions = indexPermissions;
if (subdomain) query.subdomain = subdomain;
try {
const response = await HttpLogic.values.http.get(`${route}${paramsString}`);
const response = await HttpLogic.values.http.get(route, { query });
actions.setSourceConnectData(response);
successCallback(response.oauthUrl);
} catch (e) {
@ -426,7 +453,7 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
try {
const response = await http(route, {
body: JSON.stringify({ params }),
body: JSON.stringify(params),
});
if (successCallback) successCallback();
if (isUpdating) {
@ -446,6 +473,26 @@ export const AddSourceLogic = kea<MakeLogicType<AddSourceValues, AddSourceAction
actions.setButtonNotLoading();
}
},
saveSourceParams: async ({ search }) => {
const { http } = HttpLogic.values;
const { isOrganization } = AppLogic.values;
const { navigateToUrl } = KibanaLogic.values;
const { setAddedSource } = SourcesLogic.actions;
const params = (parseQueryParams(search) as unknown) as OauthParams;
const query = { ...params, kibana_host: kibanaHost };
const route = '/api/workplace_search/sources/create';
try {
const response = await http.get(route, { query });
const { serviceName, indexPermissions, serviceType } = response;
setAddedSource(serviceName, indexPermissions, serviceType);
} catch (e) {
flashAPIErrors(e);
} finally {
navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization));
}
},
createContentSource: async ({ serviceType, successCallback, errorCallback }) => {
clearFlashMessages();
const { isOrganization } = AppLogic.values;

View file

@ -6,22 +6,22 @@
import '../../../../__mocks__/shallow_useeffect.mock';
import { setMockValues, setMockActions, mockFlashMessageHelpers } from '../../../../__mocks__';
import { setMockActions } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { Redirect, useLocation } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { Loading } from '../../../../shared/loading';
import { SourceAdded } from './source_added';
describe('SourceAdded', () => {
const { setErrorMessage } = mockFlashMessageHelpers;
const setAddedSource = jest.fn();
const saveSourceParams = jest.fn();
beforeEach(() => {
setMockActions({ setAddedSource });
setMockValues({ isOrganization: true });
setMockActions({ saveSourceParams });
});
it('renders', () => {
@ -29,26 +29,7 @@ describe('SourceAdded', () => {
(useLocation as jest.Mock).mockImplementationOnce(() => ({ search }));
const wrapper = shallow(<SourceAdded />);
expect(wrapper.find(Redirect)).toHaveLength(1);
expect(setAddedSource).toHaveBeenCalled();
});
describe('hasError', () => {
it('passes default error to server', () => {
const search = '?name=foo&hasError=true&serviceType=custom&indexPermissions=false';
(useLocation as jest.Mock).mockImplementationOnce(() => ({ search }));
shallow(<SourceAdded />);
expect(setErrorMessage).toHaveBeenCalledWith('foo failed to connect.');
});
it('passes custom error to server', () => {
const search =
'?name=foo&hasError=true&serviceType=custom&indexPermissions=false&errorMessages[]=custom error';
(useLocation as jest.Mock).mockImplementationOnce(() => ({ search }));
shallow(<SourceAdded />);
expect(setErrorMessage).toHaveBeenCalledWith('custom error');
});
expect(wrapper.find(Loading)).toHaveLength(1);
expect(saveSourceParams).toHaveBeenCalled();
});
});

View file

@ -4,52 +4,28 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { Location } from 'history';
import { useActions, useValues } from 'kea';
import { Redirect, useLocation } from 'react-router-dom';
import { useActions } from 'kea';
import { useLocation } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { Loading } from '../../../../shared/loading';
import { setErrorMessage } from '../../../../shared/flash_messages';
import { parseQueryParams } from '../../../../../applications/shared/query_params';
import { SOURCES_PATH, getSourcesPath } from '../../../routes';
import { AppLogic } from '../../../app_logic';
import { SourcesLogic } from '../sources_logic';
interface SourceQueryParams {
name: string;
hasError: boolean;
errorMessages?: string[];
serviceType: string;
indexPermissions: boolean;
}
import { AddSourceLogic } from './add_source/add_source_logic';
/**
* This component merely triggers catchs the redirect from the oauth application and initializes the saving
* of the params the oauth plugin sends back. The logic file now redirects back to sources with either a
* success or error message upon completion.
*/
export const SourceAdded: React.FC = () => {
const { search } = useLocation() as Location;
const { name, hasError, errorMessages, serviceType, indexPermissions } = (parseQueryParams(
search
) as unknown) as SourceQueryParams;
const { setAddedSource } = useActions(SourcesLogic);
const { isOrganization } = useValues(AppLogic);
const decodedName = decodeURIComponent(name);
const { saveSourceParams } = useActions(AddSourceLogic);
if (hasError) {
const defaultError = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.sources.sourceAdded.error',
{
defaultMessage: '{decodedName} failed to connect.',
values: { decodedName },
}
);
setErrorMessage(errorMessages ? errorMessages.join(' ') : defaultError);
} else {
setAddedSource(decodedName, indexPermissions, serviceType);
}
useEffect(() => {
saveSourceParams(search);
}, []);
return <Redirect to={getSourcesPath(SOURCES_PATH, isOrganization)} />;
return <Loading />;
};

View file

@ -39,6 +39,7 @@ import {
registerOrgSourceReindexJobStatusRoute,
registerOrgSourceOauthConfigurationsRoute,
registerOrgSourceOauthConfigurationRoute,
registerOauthConnectorParamsRoute,
} from './sources';
const mockConfig = {
@ -1092,6 +1093,68 @@ describe('sources routes', () => {
});
});
describe('POST /api/workplace_search/org/settings/connectors', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'post',
path: '/api/workplace_search/org/settings/connectors',
payload: 'body',
});
registerOrgSourceOauthConfigurationsRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/settings/connectors',
});
});
describe('validates', () => {
it('correctly', () => {
const request = { body: mockConfig };
mockRouter.shouldValidate(request);
});
});
});
describe('PUT /api/workplace_search/org/settings/connectors', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'put',
path: '/api/workplace_search/org/settings/connectors',
payload: 'body',
});
registerOrgSourceOauthConfigurationsRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/settings/connectors',
});
});
describe('validates', () => {
it('correctly', () => {
const request = { body: mockConfig };
mockRouter.shouldValidate(request);
});
});
});
describe('GET /api/workplace_search/org/settings/connectors/{serviceType}', () => {
let mockRouter: MockRouter;
@ -1199,4 +1262,28 @@ describe('sources routes', () => {
});
});
});
describe('GET /api/workplace_search/sources/create', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/workplace_search/sources/create',
payload: 'query',
});
registerOauthConnectorParamsRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/sources/create',
});
});
});
});

View file

@ -25,14 +25,14 @@ const pageSchema = schema.object({
total_results: schema.number(),
});
const oAuthConfigSchema = schema.object({
const oauthConfigSchema = schema.object({
base_url: schema.maybe(schema.string()),
client_id: schema.maybe(schema.string()),
client_secret: schema.maybe(schema.string()),
service_type: schema.string(),
private_key: schema.string(),
public_key: schema.string(),
consumer_key: schema.string(),
private_key: schema.maybe(schema.string()),
public_key: schema.maybe(schema.string()),
consumer_key: schema.maybe(schema.string()),
});
const displayFieldSchema = schema.object({
@ -50,6 +50,7 @@ const displaySettingsSchema = schema.object({
detailFields: schema.oneOf([schema.arrayOf(displayFieldSchema), displayFieldSchema]),
});
// Account routes
export function registerAccountSourcesRoute({
router,
enterpriseSearchRequestHandler,
@ -252,6 +253,10 @@ export function registerAccountPrepareSourcesRoute({
params: schema.object({
serviceType: schema.string(),
}),
query: schema.object({
kibana_host: schema.string(),
subdomain: schema.maybe(schema.string()),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
@ -390,6 +395,7 @@ export function registerAccountSourceReindexJobStatusRoute({
);
}
// Org routes
export function registerOrgSourcesRoute({
router,
enterpriseSearchRequestHandler,
@ -592,6 +598,11 @@ export function registerOrgPrepareSourcesRoute({
params: schema.object({
serviceType: schema.string(),
}),
query: schema.object({
kibana_host: schema.string(),
index_permissions: schema.boolean(),
subdomain: schema.maybe(schema.string()),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
@ -743,6 +754,30 @@ export function registerOrgSourceOauthConfigurationsRoute({
path: '/ws/org/settings/connectors',
})
);
router.post(
{
path: '/api/workplace_search/org/settings/connectors',
validate: {
body: oauthConfigSchema,
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/settings/connectors',
})
);
router.put(
{
path: '/api/workplace_search/org/settings/connectors',
validate: {
body: oauthConfigSchema,
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/settings/connectors',
})
);
}
export function registerOrgSourceOauthConfigurationRoute({
@ -770,7 +805,7 @@ export function registerOrgSourceOauthConfigurationRoute({
params: schema.object({
serviceType: schema.string(),
}),
body: oAuthConfigSchema,
body: oauthConfigSchema,
},
},
enterpriseSearchRequestHandler.createRequest({
@ -785,7 +820,7 @@ export function registerOrgSourceOauthConfigurationRoute({
params: schema.object({
serviceType: schema.string(),
}),
body: oAuthConfigSchema,
body: oauthConfigSchema,
},
},
enterpriseSearchRequestHandler.createRequest({
@ -808,6 +843,30 @@ export function registerOrgSourceOauthConfigurationRoute({
);
}
// Same route is used for org and account. `state` passes the context.
export function registerOauthConnectorParamsRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/workplace_search/sources/create',
validate: {
query: schema.object({
kibana_host: schema.string(),
code: schema.string(),
session_state: schema.string(),
state: schema.string(),
oauth_verifier: schema.maybe(schema.string()),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/sources/create',
})
);
}
export const registerSourcesRoutes = (dependencies: RouteDependencies) => {
registerAccountSourcesRoute(dependencies);
registerAccountSourcesStatusRoute(dependencies);
@ -841,4 +900,5 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => {
registerOrgSourceReindexJobStatusRoute(dependencies);
registerOrgSourceOauthConfigurationsRoute(dependencies);
registerOrgSourceOauthConfigurationRoute(dependencies);
registerOauthConnectorParamsRoute(dependencies);
};