Skip to content

Commit

Permalink
[CLOUD] Add internal API to set solution in space (#191553)
Browse files Browse the repository at this point in the history
## Summary

Add `/internal/spaces/space/default/solution'` with an attribute
solution_type for Control Panel to set the solution for the left
navigation in kibana for an instant deployment.

```
curl -X PUT "http://yourserver/internal/spaces/space/default/solution'" \
     -H "kbn-xsrf: kibana" \
     -H "x-elastic-internal-origin: cloud"
     -H "Content-Type: application/json" \
     -d '{"solution_type": "observability"}'
```


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
XavierM and kibanamachine authored Sep 5, 2024
1 parent 1ad8851 commit fd084d9
Show file tree
Hide file tree
Showing 10 changed files with 556 additions and 1 deletion.
2 changes: 1 addition & 1 deletion x-pack/plugins/spaces/server/lib/space_schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions x-pack/plugins/spaces/server/routes/api/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,4 +19,5 @@ export interface InternalRouteDeps {
export function initInternalSpacesApi(deps: InternalRouteDeps) {
initGetActiveSpaceApi(deps);
initGetSpaceContentSummaryApi(deps);
initSetSolutionSpaceApi(deps);
}
Original file line number Diff line number Diff line change
@@ -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',
});
});
});
Original file line number Diff line number Diff line change
@@ -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 });
})
);
}
1 change: 1 addition & 0 deletions x-pack/plugins/spaces/server/routes/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

export { convertSavedObjectToSpace } from './convert_saved_object_to_space';
export { createLicensedRouteHandler } from './licensed_route_handler';
export { parseCloudSolution } from './parse_cloud_solution';
Original file line number Diff line number Diff line change
@@ -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/
);
});
});
30 changes: 30 additions & 0 deletions x-pack/plugins/spaces/server/routes/lib/parse_cloud_solution.ts
Original file line number Diff line number Diff line change
@@ -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<string, SolutionView>([
['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;
}
9 changes: 9 additions & 0 deletions x-pack/test/api_integration/apis/spaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
};
}
1 change: 1 addition & 0 deletions x-pack/test/api_integration/apis/spaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
}
Loading

0 comments on commit fd084d9

Please sign in to comment.