diff --git a/x-pack/plugins/spaces/server/lib/space_schema.ts b/x-pack/plugins/spaces/server/lib/space_schema.ts index f43ca3bc58f53..852e1a98096c4 100644 --- a/x-pack/plugins/spaces/server/lib/space_schema.ts +++ b/x-pack/plugins/spaces/server/lib/space_schema.ts @@ -44,7 +44,7 @@ const spaceSchema = schema.object({ ), }); -const solutionSchema = schema.oneOf([ +export const solutionSchema = schema.oneOf([ schema.literal('security'), schema.literal('oblt'), schema.literal('es'), diff --git a/x-pack/plugins/spaces/server/routes/api/internal/index.ts b/x-pack/plugins/spaces/server/routes/api/internal/index.ts index 5e2120d896b87..3212b2fe0a1c2 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/index.ts @@ -7,6 +7,7 @@ import { initGetActiveSpaceApi } from './get_active_space'; import { initGetSpaceContentSummaryApi } from './get_content_summary'; +import { initSetSolutionSpaceApi } from './set_solution_space'; import type { SpacesServiceStart } from '../../../spaces_service/spaces_service'; import type { SpacesRouter } from '../../../types'; @@ -18,4 +19,5 @@ export interface InternalRouteDeps { export function initInternalSpacesApi(deps: InternalRouteDeps) { initGetActiveSpaceApi(deps); initGetSpaceContentSummaryApi(deps); + initSetSolutionSpaceApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.test.ts new file mode 100644 index 0000000000000..181309d1ea731 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as Rx from 'rxjs'; + +import type { RouteValidatorConfig } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { coreMock, httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; + +import { initSetSolutionSpaceApi } from './set_solution_space'; +import { spacesConfig } from '../../../lib/__fixtures__'; +import { SpacesClientService } from '../../../spaces_client'; +import { SpacesService } from '../../../spaces_service'; +import { + createMockSavedObjectsRepository, + createSpaces, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; + +describe('PUT /internal/spaces/space/{id}/solution', () => { + const spacesSavedObjects = createSpaces(); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + + const coreStart = coreMock.createStart(); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const clientService = new SpacesClientService(jest.fn(), 'traditional'); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, + }); + + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); + + initSetSolutionSpaceApi({ + router, + getSpacesService: () => spacesServiceStart, + }); + + const [routeDefinition, routeHandler] = router.put.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler, + savedObjectsRepositoryMock, + }; + }; + + it('should update the solution an existing space with the provided ID', async () => { + const payload = { + solution: 'oblt', + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + solution: 'oblt', + name: 'a space', + color: undefined, + description: undefined, + disabledFeatures: [], + imageUrl: undefined, + initials: undefined, + }); + }); + + it('should update the solution_type an existing space with the provided ID', async () => { + const payload = { + solution_type: 'observability', + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-space', + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + solution: 'oblt', + name: 'a space', + color: undefined, + description: undefined, + disabledFeatures: [], + imageUrl: undefined, + initials: undefined, + }); + }); + + it('should not allow a new space to be created', async () => { + const payload = { + solution: 'oblt', + }; + + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + id: 'a-new-space', + }, + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(404); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts new file mode 100644 index 0000000000000..6732a8520946d --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/set_solution_space.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; + +import type { InternalRouteDeps } from '.'; +import type { SolutionView, Space } from '../../../../common'; +import { wrapError } from '../../../lib/errors'; +import { solutionSchema } from '../../../lib/space_schema'; +import { createLicensedRouteHandler, parseCloudSolution } from '../../lib'; + +const spaceSolutionSchema = schema.oneOf([ + schema.object({ solution: solutionSchema }), + schema.object({ + solution_type: schema.oneOf([ + schema.literal('security'), + schema.literal('observability'), + schema.literal('elasticsearch'), + ]), + }), +]); + +/* FUTURE Engineer + * This route /internal/spaces/space/{id}/solution is and will be used by cloud (control panel) + * to set the solution of a default space for an instant deployment + * and it will use the parameter "solution_type" + */ + +export function initSetSolutionSpaceApi(deps: InternalRouteDeps) { + const { router, getSpacesService } = deps; + + router.put( + { + path: '/internal/spaces/space/{id}/solution', + options: { + description: `Update solution for a space`, + }, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: spaceSolutionSchema, + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const spacesClient = (await getSpacesService()).createSpacesClient(request); + const id = request.params.id; + let solutionToUpdate: SolutionView | undefined; + + let result: Space; + try { + if ('solution' in request.body) { + solutionToUpdate = request.body.solution; + } else { + solutionToUpdate = parseCloudSolution(request.body.solution_type); + } + const space = await spacesClient?.get(id); + result = await spacesClient.update(id, { ...space, solution: solutionToUpdate }); + } catch (error) { + if (SavedObjectsErrorHelpers.isNotFoundError(error)) { + return response.notFound(); + } + return response.customError(wrapError(error)); + } + + return response.ok({ body: result }); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/lib/index.ts b/x-pack/plugins/spaces/server/routes/lib/index.ts index e243e26d8c21f..b76b9a789a83e 100644 --- a/x-pack/plugins/spaces/server/routes/lib/index.ts +++ b/x-pack/plugins/spaces/server/routes/lib/index.ts @@ -7,3 +7,4 @@ export { convertSavedObjectToSpace } from './convert_saved_object_to_space'; export { createLicensedRouteHandler } from './licensed_route_handler'; +export { parseCloudSolution } from './parse_cloud_solution'; diff --git a/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.test.ts b/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.test.ts new file mode 100644 index 0000000000000..92ab37ec7359a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parseCloudSolution } from './parse_cloud_solution'; + +describe('parseCloudSolution', () => { + // Test valid cases + it('should return "es" for "elasticsearch"', () => { + expect(parseCloudSolution('elasticsearch')).toBe('es'); + }); + + it('should return "oblt" for "observability"', () => { + expect(parseCloudSolution('observability')).toBe('oblt'); + }); + + it('should return "security" for "security"', () => { + expect(parseCloudSolution('security')).toBe('security'); + }); + + // Test case insensitivity + it('should return "es" for "ELASTICSEARCH"', () => { + expect(parseCloudSolution('ELASTICSEARCH')).toBe('es'); + }); + + it('should return "oblt" for "OBSERVABILITY"', () => { + expect(parseCloudSolution('OBSERVABILITY')).toBe('oblt'); + }); + + it('should return "security" for "SECURITY"', () => { + expect(parseCloudSolution('SECURITY')).toBe('security'); + }); + + // Test for undefined or missing inputs + it('should throw an error when value is undefined', () => { + expect(() => parseCloudSolution(undefined)).toThrow( + /undefined is not a valid solution value set by Cloud/ + ); + }); + + it('should throw an error when value is null', () => { + expect(() => parseCloudSolution(null as unknown as string)).toThrow( + /null is not a valid solution value set by Cloud/ + ); + }); + + // Test invalid values + it('should throw an error for invalid values', () => { + expect(() => parseCloudSolution('invalid')).toThrow( + /invalid is not a valid solution value set by Cloud/ + ); + }); + + it('should throw an error for empty string', () => { + expect(() => parseCloudSolution('')).toThrow(/ is not a valid solution value set by Cloud/); + }); + + it('should throw an error for unlisted valid input', () => { + expect(() => parseCloudSolution('unlisted')).toThrow( + /unlisted is not a valid solution value set by Cloud/ + ); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.ts b/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.ts new file mode 100644 index 0000000000000..4532c26fc6686 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SolutionView } from '../../../common'; + +const CLOUD_TO_KIBANA_SOLUTION_MAP = new Map([ + ['elasticsearch', 'es'], + ['observability', 'oblt'], + ['security', 'security'], +]); + +/** + * Cloud does not type the value of the "use case" that is set during onboarding for a deployment. Any string can + * be passed. This function maps the known values to the Kibana values. + * + * @param value The solution value set by Cloud. + * @returns The default solution value for onboarding that matches Kibana naming. + */ +export function parseCloudSolution(value?: string): SolutionView { + const parsedValue = value ? CLOUD_TO_KIBANA_SOLUTION_MAP.get(value.toLowerCase()) : undefined; + if (!parsedValue) { + throw new Error(`${value} is not a valid solution value set by Cloud`); + } + + return parsedValue; +} diff --git a/x-pack/test/api_integration/apis/spaces/config.ts b/x-pack/test/api_integration/apis/spaces/config.ts index 5f335f116fefe..87000e8fc5427 100644 --- a/x-pack/test/api_integration/apis/spaces/config.ts +++ b/x-pack/test/api_integration/apis/spaces/config.ts @@ -13,5 +13,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { return { ...baseIntegrationTestsConfig.getAll(), testFiles: [require.resolve('.')], + kbnTestServer: { + ...baseIntegrationTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...baseIntegrationTestsConfig.get('kbnTestServer.serverArgs'), + '--xpack.cloud.id="ftr_fake_cloud_id:aGVsbG8uY29tOjQ0MyRFUzEyM2FiYyRrYm4xMjNhYmM="', + '--xpack.cloud.base_url="https://cloud.elastic.co"', + '--xpack.spaces.allowSolutionVisibility=true', + ], + }, }; } diff --git a/x-pack/test/api_integration/apis/spaces/index.ts b/x-pack/test/api_integration/apis/spaces/index.ts index 6fd7a11458c56..634b74806a645 100644 --- a/x-pack/test/api_integration/apis/spaces/index.ts +++ b/x-pack/test/api_integration/apis/spaces/index.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./saved_objects')); loadTestFile(require.resolve('./space_attributes')); loadTestFile(require.resolve('./get_content_summary')); + loadTestFile(require.resolve('./set_solution_space')); }); } diff --git a/x-pack/test/api_integration/apis/spaces/set_solution_space.ts b/x-pack/test/api_integration/apis/spaces/set_solution_space.ts new file mode 100644 index 0000000000000..8019b59ca817d --- /dev/null +++ b/x-pack/test/api_integration/apis/spaces/set_solution_space.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const spacesService = getService('spaces'); + + describe('PUT /internal/spaces/space/{id}/solution', () => { + describe('For default space', () => { + beforeEach(async () => { + await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'classic', + }) + .expect(200); + }); + + it('Use solution_type param to set solution', async () => { + await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution_type: 'observability', + }) + .expect(200) + .then((response) => { + const { solution, name, id } = response.body; + expect({ solution, name, id }).to.eql({ + id: 'default', + name: 'Default', + solution: 'oblt', + }); + }); + }); + + it('Use solution param to set solution', async () => { + await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'oblt', + }) + .expect(200) + .then((response) => { + const { solution, name, id } = response.body; + expect({ solution, name, id }).to.eql({ + solution: 'oblt', + id: 'default', + name: 'Default', + }); + }); + }); + }); + + describe('For Foo Space space', () => { + before(async () => { + await spacesService.create({ + id: 'foo-space', + name: 'Foo Space', + disabledFeatures: [], + color: '#AABBCC', + }); + }); + + after(async () => { + await spacesService.delete('foo-space'); + }); + + beforeEach(async () => { + await supertest + .put('/internal/spaces/space/foo-space/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'classic', + }) + .expect(200); + }); + + it('Use solution_type param to set solution for Foo Space space', async () => { + await supertest + .put('/internal/spaces/space/foo-space/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution_type: 'observability', + }) + .expect(200) + .then((response) => { + const { solution, name, id } = response.body; + expect({ solution, name, id }).to.eql({ + id: 'foo-space', + name: 'Foo Space', + solution: 'oblt', + }); + }); + }); + + it('Use solution param to set solution for Foo Space space', async () => { + await supertest + .put('/internal/spaces/space/foo-space/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'oblt', + }) + .expect(200) + .then((response) => { + const { solution, name, id } = response.body; + expect({ solution, name, id }).to.eql({ + solution: 'oblt', + id: 'foo-space', + name: 'Foo Space', + }); + }); + }); + }); + + it('throw error if solution_type is not supported', async () => { + const { body } = await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution_type: 'miami', + }) + .expect(400); + + expect(body.message).to.eql( + '[request body]: types that failed validation:\n- [request body.0.solution]: expected at least one defined value but got [undefined]\n- [request body.1.solution_type]: types that failed validation:\n - [request body.solution_type.0]: expected value to equal [security]\n - [request body.solution_type.1]: expected value to equal [observability]\n - [request body.solution_type.2]: expected value to equal [elasticsearch]' + ); + }); + + it('throw error if solution is not supported', async () => { + const { body } = await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'miami', + }) + .expect(400); + + expect(body.message).to.eql( + '[request body]: types that failed validation:\n- [request body.0.solution]: types that failed validation:\n - [request body.solution.0]: expected value to equal [security]\n - [request body.solution.1]: expected value to equal [oblt]\n - [request body.solution.2]: expected value to equal [es]\n - [request body.solution.3]: expected value to equal [classic]\n- [request body.1.solution_type]: expected at least one defined value but got [undefined]' + ); + }); + + it('throw error if solution and solution_type are defined', async () => { + const { body } = await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({ + solution: 'oblt', + solution_type: 'observability', + }) + .expect(400); + + expect(body.message).to.eql( + '[request body]: types that failed validation:\n- [request body.0.solution_type]: definition for this key is missing\n- [request body.1.solution]: definition for this key is missing' + ); + }); + + it('throw error if solution and solution_type are not defined', async () => { + const { body } = await supertest + .put('/internal/spaces/space/default/solution') + .set('kbn-xsrf', 'xxx') + .set('x-elastic-internal-origin', 'cloud') + .send({}) + .expect(400); + + expect(body.message).to.eql( + '[request body]: types that failed validation:\n- [request body.0.solution]: expected at least one defined value but got [undefined]\n- [request body.1.solution_type]: expected at least one defined value but got [undefined]' + ); + }); + + it('returns 404 when the space is not found', async () => { + await supertest + .get('/internal/spaces/space/not-found-space/solution') + .set('kbn-xsrf', 'xxx') + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + }); + }); +}