diff --git a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js index cfa37ff2e0358..744acf0a41132 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/__jest__/client_integration/follower_index_edit.test.js @@ -5,8 +5,9 @@ */ import '../../public/np_ready/app/services/breadcrumbs.mock'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers, nextTick, getRandomString } from './helpers'; import { FollowerIndexForm } from '../../public/np_ready/app/components/follower_index_form/follower_index_form'; +import { endPoints } from '../../common/routes_endpoints'; import { FOLLOWER_INDEX_EDIT } from './helpers/constants'; jest.mock('ui/new_platform'); @@ -14,7 +15,7 @@ jest.mock('ui/new_platform'); const { setup } = pageHelpers.followerIndexEdit; const { setup: setupFollowerIndexAdd } = pageHelpers.followerIndexAdd; -describe('Edit Auto-follow pattern', () => { +describe('Edit follower index', () => { let server; let httpRequestsMockHelpers; @@ -29,16 +30,16 @@ describe('Edit Auto-follow pattern', () => { describe('on component mount', () => { let find; let component; + let waitFor; const remoteClusters = [{ name: 'new-york', seeds: ['localhost:123'], isConnected: true }]; beforeEach(async () => { httpRequestsMockHelpers.setLoadRemoteClustersResponse(remoteClusters); httpRequestsMockHelpers.setGetFollowerIndexResponse(FOLLOWER_INDEX_EDIT); - ({ component, find } = setup()); + ({ component, find, waitFor } = setup()); - await nextTick(); - component.update(); + await waitFor('followerIndexForm'); }); /** @@ -90,6 +91,54 @@ describe('Edit Auto-follow pattern', () => { }); }); + describe('payload and API endpoint validation', () => { + const remoteClusters = [{ name: 'new-york', seeds: ['localhost:123'], isConnected: true }]; + let testBed; + + beforeEach(async () => { + httpRequestsMockHelpers.setLoadRemoteClustersResponse(remoteClusters); + httpRequestsMockHelpers.setGetFollowerIndexResponse(FOLLOWER_INDEX_EDIT); + + testBed = await setup(); + await testBed.waitFor('followerIndexForm'); + }); + + test('should send the correct payload', async () => { + const { actions, form, component, find, waitFor } = testBed; + const maxRetryDelay = getRandomString(); + + form.setInputValue('maxRetryDelayInput', maxRetryDelay); + + actions.clickSaveForm(); + component.update(); // The modal to confirm the update opens + await waitFor('confirmModalTitleText'); + find('confirmModalConfirmButton').simulate('click'); + + await nextTick(); // Make sure the Request went through + + const { method, path } = endPoints.get('followerIndex', 'edit', { + id: FOLLOWER_INDEX_EDIT.name, + }); + + // const path = `${API_BASE_PATH}/follower_indices/${FOLLOWER_INDEX_EDIT.name}`; + // const method = 'post'; + + const expectedBody = { ...FOLLOWER_INDEX_EDIT, maxRetryDelay }; + delete expectedBody.name; + delete expectedBody.remoteCluster; + delete expectedBody.leaderIndex; + delete expectedBody.status; + + const latestRequest = server.requests[server.requests.length - 1]; + const requestBody = JSON.parse(JSON.parse(latestRequest.requestBody).body); + + // Validate the API endpoint called: method, path and payload + expect(latestRequest.method).toBe(method.toUpperCase()); + expect(latestRequest.url).toBe(path); + expect(requestBody).toEqual(expectedBody); + }); + }); + describe('when the remote cluster is disconnected', () => { let find; let exists; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/my_lib.ts b/x-pack/legacy/plugins/cross_cluster_replication/common/my_lib.ts new file mode 100644 index 0000000000000..b23b9b2fe4786 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/common/my_lib.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +type Method = 'get' | 'post' | 'put' | 'delete' | 'head'; + +export interface EndpointDefinition { + method: Method; + path: string; +} + +const replacePlaceholders = (path: string, data: { [key: string]: any }): string => + Object.entries(data).reduce((updatedPath, [key, value]) => { + const regEx = new RegExp(`{${key}}`); + return updatedPath.replace(regEx, value); + }, path); + +export const createEndPointsGetter = (appEndPoints: T) => < + S extends keyof T, + E extends keyof T[S] +>( + section: S, + endpoint: E, + data?: { [key: string]: any } +): EndpointDefinition => { + const endpointDefinition = (appEndPoints[section][endpoint] as unknown) as EndpointDefinition; + + return data + ? { ...endpointDefinition, path: replacePlaceholders(endpointDefinition.path, data) } // Replace placeholders with data + : endpointDefinition; +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/common/routes_endpoints.ts b/x-pack/legacy/plugins/cross_cluster_replication/common/routes_endpoints.ts new file mode 100644 index 0000000000000..a55d52ae07800 --- /dev/null +++ b/x-pack/legacy/plugins/cross_cluster_replication/common/routes_endpoints.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * -------------------------------------------------------- + * Single source of truth for the CCR routes endpoints + * To be used in server, client and api integration tests + * -------------------------------------------------------- + */ + +// Reusable lib accross our apps +import { EndpointDefinition, createEndPointsGetter } from './my_lib'; + +import { API_BASE_PATH } from './constants'; + +// Follower indices +const followerIndexEndpoints = { + list: { + method: 'get', + path: `${API_BASE_PATH}/follower_indices`, + } as EndpointDefinition, + get: { + method: 'get', + path: `${API_BASE_PATH}/follower_indices/{id}`, + } as EndpointDefinition, + create: { + method: 'post', + path: `${API_BASE_PATH}/follower_indices`, + } as EndpointDefinition, + edit: { + method: 'put', + path: `${API_BASE_PATH}/follower_indices/{id}`, + } as EndpointDefinition, + delete: { + method: 'delete', + path: `${API_BASE_PATH}/follower_indices/{id}`, + } as EndpointDefinition, +}; + +const autoFollowPatternEndpoints = { + list: { + method: 'get', + path: `${API_BASE_PATH}/auto-follow-patterns`, + } as EndpointDefinition, + get: { + method: 'get', + path: `${API_BASE_PATH}/auto-follow-patterns/{id}`, + } as EndpointDefinition, + create: { + method: 'post', + path: `${API_BASE_PATH}/auto-follow-patterns`, + } as EndpointDefinition, + edit: { + method: 'put', + path: `${API_BASE_PATH}/auto-follow-patterns/{id}`, + } as EndpointDefinition, + delete: { + method: 'delete', + path: `${API_BASE_PATH}/auto-follow-patterns/{id}`, + } as EndpointDefinition, + pause: { + method: 'post', + path: `${API_BASE_PATH}/auto-follow-patterns/{id}/pause`, + } as EndpointDefinition, + resume: { + method: 'post', + path: `${API_BASE_PATH}/auto-follow-patterns/{id}/resume`, + } as EndpointDefinition, +}; + +// Redux like reducer composition +const ccrRoutesEndpoints = { + followerIndex: followerIndexEndpoints, + autoFollowPattern: autoFollowPatternEndpoints, +}; + +export const endPoints = { + get: createEndPointsGetter(ccrRoutesEndpoints), +}; diff --git a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js index 24bc7e17356e2..5ace3fb37deb6 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js +++ b/x-pack/legacy/plugins/cross_cluster_replication/public/np_ready/app/services/api.js @@ -8,6 +8,7 @@ import { API_REMOTE_CLUSTERS_BASE_PATH, API_INDEX_MANAGEMENT_BASE_PATH, } from '../../../../common/constants'; +import { getEndpoint } from '../../../../common/routes_endpoints'; import { arrify } from '../../../../common/services/utils'; import { UIM_FOLLOWER_INDEX_CREATE, @@ -159,7 +160,9 @@ export const updateFollowerIndex = (id, followerIndex) => { readPollTimeout, } = followerIndex; - const request = httpClient.put(`${API_BASE_PATH}/follower_indices/${encodeURIComponent(id)}`, { + const { path, method } = getEndpoint('followerIndex', 'edit', { id: encodeURIComponent(id) }); + + const request = httpClient[method](path, { body: JSON.stringify({ maxReadRequestOperationCount, maxOutstandingReadRequests, diff --git a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts index 1d7dacf4a8688..2dafb660d6ca7 100644 --- a/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts +++ b/x-pack/legacy/plugins/cross_cluster_replication/server/np_ready/routes/api/follower_index.ts @@ -12,6 +12,7 @@ import { // @ts-ignore } from '../../../../common/services/follower_index_serialization'; import { API_BASE_PATH } from '../../../../common/constants'; +import { endPoints } from '../../../../common/routes_endpoints'; // @ts-ignore import { removeEmptyFields } from '../../../../common/services/utils'; // @ts-ignore @@ -21,6 +22,34 @@ import { licensePreRoutingFactory } from '../../lib/license_pre_routing_factory' import { RouteDependencies } from '../types'; import { mapErrorToKibanaHttpResponse } from '../map_to_kibana_http_error'; +// ------------------ +// Validation schemas +// ------------------ +const advancedSettings = { + maxReadRequestOperationCount: schema.maybe(schema.number()), + maxOutstandingReadRequests: schema.maybe(schema.number()), + maxReadRequestSize: schema.maybe(schema.string()), // byte value + maxWriteRequestOperationCount: schema.maybe(schema.number()), + maxWriteRequestSize: schema.maybe(schema.string()), // byte value + maxOutstandingWriteRequests: schema.maybe(schema.number()), + maxWriteBufferCount: schema.maybe(schema.number()), + maxWriteBufferSize: schema.maybe(schema.string()), // byte value + maxRetryDelay: schema.maybe(schema.string()), // time value + readPollTimeout: schema.maybe(schema.string()), // time value +}; + +const basicSettingsSchema = { + name: schema.string(), + remoteCluster: schema.string(), + leaderIndex: schema.string(), +}; + +const followerIndexSchema = schema.object({ ...basicSettingsSchema, ...advancedSettings }); +const advancedSettingsSchema = schema.object({ ...advancedSettings }); + +// ------------------ +// Routes definition +// ------------------ export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependencies) => { /** * Returns a list of all follower indices @@ -130,12 +159,7 @@ export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependenc { path: `${API_BASE_PATH}/follower_indices`, validate: { - body: schema.object( - { - name: schema.string(), - }, - { unknowns: 'allow' } - ), + body: followerIndexSchema, }, }, licensePreRoutingFactory({ @@ -161,21 +185,10 @@ export const registerFollowerIndexRoutes = ({ router, __LEGACY }: RouteDependenc */ router.put( { - path: `${API_BASE_PATH}/follower_indices/{id}`, + path: endPoints.get('autoFollowPattern', 'delete').path, validate: { params: schema.object({ id: schema.string() }), - body: schema.object({ - maxReadRequestOperationCount: schema.maybe(schema.number()), - maxOutstandingReadRequests: schema.maybe(schema.number()), - maxReadRequestSize: schema.maybe(schema.string()), // byte value - maxWriteRequestOperationCount: schema.maybe(schema.number()), - maxWriteRequestSize: schema.maybe(schema.string()), // byte value - maxOutstandingWriteRequests: schema.maybe(schema.number()), - maxWriteBufferCount: schema.maybe(schema.number()), - maxWriteBufferSize: schema.maybe(schema.string()), // byte value - maxRetryDelay: schema.maybe(schema.string()), // time value - readPollTimeout: schema.maybe(schema.string()), // time value - }), + body: advancedSettingsSchema, }, }, licensePreRoutingFactory({ diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.helpers.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.helpers.js index dbcb6bf819749..d611db77463dd 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.helpers.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.helpers.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { getEndpoint } from '../../../../../legacy/plugins/cross_cluster_replication/common/routes_endpoints'; import { API_BASE_PATH } from './constants'; import { getRandomString } from './lib'; import { getFollowerIndexPayload } from './fixtures'; @@ -13,7 +14,45 @@ export const registerHelpers = supertest => { const loadFollowerIndices = () => supertest.get(`${API_BASE_PATH}/follower_indices`); - const getFollowerIndex = name => supertest.get(`${API_BASE_PATH}/follower_indices/${name}`); + const getFollowerIndex = (name, waitUntilIsActive = false) => { + const maxRetries = 10; + const delayBetweenRetries = 500; + let retryCount = 0; + + const proceed = async () => { + const response = await supertest.get(`${API_BASE_PATH}/follower_indices/${name}`); + + if (waitUntilIsActive && response.body.status !== 'active') { + retryCount += 1; + + if (retryCount > maxRetries) { + throw new Error('Error waiting for follower index to be active.'); + } + + return new Promise(resolve => setTimeout(resolve, delayBetweenRetries)).then(proceed); + } + + return response; + }; + + return { + expect: status => + new Promise((resolve, reject) => + proceed() + .then(response => { + if (status !== response.status) { + reject(new Error(`Expected status ${status} but got ${response.status}`)); + } + return resolve(response); + }) + .catch(reject) + ), + then: (resolve, reject) => + proceed() + .then(resolve) + .catch(reject), + }; + }; const createFollowerIndex = (name = getRandomString(), payload = getFollowerIndexPayload()) => { followerIndicesCreated.push(name); @@ -24,6 +63,14 @@ export const registerHelpers = supertest => { .send({ ...payload, name }); }; + const updateFollowerIndex = (name, payload) => { + const { method, path } = getEndpoint('followerIndex', 'edit', { id: name }); + + return supertest[method](path) + .set('kbn-xsrf', 'xxx') + .send(payload); + }; + const unfollowLeaderIndex = followerIndex => { const followerIndices = Array.isArray(followerIndex) ? followerIndex : [followerIndex]; const followerIndicesToEncodedString = followerIndices @@ -51,6 +98,7 @@ export const registerHelpers = supertest => { loadFollowerIndices, getFollowerIndex, createFollowerIndex, + updateFollowerIndex, unfollowAll, }; }; diff --git a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js index 5f9ebbd2a0a3f..eabf474120f2b 100644 --- a/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js +++ b/x-pack/test/api_integration/apis/management/cross_cluster_replication/follower_indices.js @@ -21,6 +21,7 @@ export default function({ getService }) { loadFollowerIndices, getFollowerIndex, createFollowerIndex, + updateFollowerIndex, unfollowAll, } = registerFollowerIndicesnHelpers(supertest); @@ -92,6 +93,31 @@ export default function({ getService }) { }); }); + describe('update()', () => { + it('should update a follower index advanced settings', async () => { + // Create a follower index + const leaderIndex = await createIndex(); + const followerIndex = getRandomString(); + const initialValue = 1234; + const payload = getFollowerIndexPayload(leaderIndex, undefined, { + maxReadRequestOperationCount: initialValue, + }); + await createFollowerIndex(followerIndex, payload); + + // Verify that its advanced settings are correctly set + const { body } = await getFollowerIndex(followerIndex, true); + expect(body.maxReadRequestOperationCount).to.be(initialValue); + + // Update the follower index + const updatedValue = 7777; + await updateFollowerIndex(followerIndex, { maxReadRequestOperationCount: updatedValue }); + + // Verify that the advanced settings are updated + const { body: updatedBody } = await getFollowerIndex(followerIndex, true); + expect(updatedBody.maxReadRequestOperationCount).to.be(updatedValue); + }); + }); + describe('Advanced settings', () => { it('hard-coded values should match Elasticsearch default values', async () => { /**