diff --git a/docs/api/spaces-management/delete.asciidoc b/docs/api/spaces-management/delete.asciidoc new file mode 100644 index 00000000000000..c5ae025dd9e2e9 --- /dev/null +++ b/docs/api/spaces-management/delete.asciidoc @@ -0,0 +1,25 @@ +[[spaces-api-delete]] +=== Delete space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +[WARNING] +================================================== +Deleting a space will automatically delete all saved objects that belong to that space. This operation cannot be undone! +================================================== + +==== Request + +To delete a space, submit a DELETE request to the `/api/spaces/space/` +endpoint: + +[source,js] +-------------------------------------------------- +DELETE /api/spaces/space/marketing +-------------------------------------------------- +// KIBANA + +==== Response + +If the space is successfully deleted, the response code is `204`; otherwise, the response +code is 404. diff --git a/docs/api/spaces-management/get.asciidoc b/docs/api/spaces-management/get.asciidoc new file mode 100644 index 00000000000000..c79a883a80e4bb --- /dev/null +++ b/docs/api/spaces-management/get.asciidoc @@ -0,0 +1,77 @@ +[[spaces-api-get]] +=== Get Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Retrieves all {kib} spaces, or a specific space. + +==== Get all {kib} spaces + +===== Request + +To retrieve all spaces, issue a GET request to the +/api/spaces/space endpoint. + +[source,js] +-------------------------------------------------- +GET /api/spaces/space +-------------------------------------------------- +// KIBANA + +===== Response + +A successful call returns a response code of `200` and a response body containing a JSON +representation of the spaces. + +[source,js] +-------------------------------------------------- +[ + { + "id": "default", + "name": "Default", + "description" : "This is the Default Space", + "_reserved": true + }, + { + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" + }, + { + "id": "sales", + "name": "Sales", + "initials": "MK" + }, +] +-------------------------------------------------- + +==== Get a specific space + +===== Request + +To retrieve a specific space, issue a GET request to +the `/api/spaces/space/` endpoint: + +[source,js] +-------------------------------------------------- +GET /api/spaces/space/marketing +-------------------------------------------------- +// KIBANA + +===== Response + +A successful call returns a response code of `200` and a response body containing a JSON +representation of the space. + +[source,js] +-------------------------------------------------- +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- diff --git a/docs/api/spaces-management/post.asciidoc b/docs/api/spaces-management/post.asciidoc new file mode 100644 index 00000000000000..569835c78b2f80 --- /dev/null +++ b/docs/api/spaces-management/post.asciidoc @@ -0,0 +1,50 @@ +[[spaces-api-post]] +=== Create Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Creates a new {kib} space. To update an existing space, use the PUT command. + +==== Request + +To create a space, issue a POST request to the +`/api/spaces/space` endpoint. + +[source,js] +-------------------------------------------------- +PUT /api/spaces/space +-------------------------------------------------- + +==== Request Body + +The following parameters can be specified in the body of a POST request to create a space: + +`id`:: (string) Required identifier for the space. This identifier becomes part of Kibana's URL when inside the space. This cannot be changed by the update operation. + +`name`:: (string) Required display name for the space. + +`description`:: (string) Optional description for the space. + +`initials`:: (string) Optionally specify the initials shown in the Space Avatar for this space. By default, the initials will be automatically generated from the space name. +If specified, initials should be either 1 or 2 characters. + +`color`:: (string) Optioanlly specify the hex color code used in the Space Avatar for this space. By default, the color will be automatically generated from the space name. + +===== Example + +[source,js] +-------------------------------------------------- +POST /api/spaces/space +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- +// KIBANA + +==== Response + +A successful call returns a response code of `200` with the created Space. diff --git a/docs/api/spaces-management/put.asciidoc b/docs/api/spaces-management/put.asciidoc new file mode 100644 index 00000000000000..529742bf2ce666 --- /dev/null +++ b/docs/api/spaces-management/put.asciidoc @@ -0,0 +1,50 @@ +[[spaces-api-put]] +=== Update Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Updates an existing {kib} space. To create a new space, use the POST command. + +==== Request + +To update a space, issue a PUT request to the +`/api/spaces/space/` endpoint. + +[source,js] +-------------------------------------------------- +PUT /api/spaces/space/ +-------------------------------------------------- + +==== Request Body + +The following parameters can be specified in the body of a PUT request to update a space: + +`id`:: (string) Required identifier for the space. This identifier becomes part of Kibana's URL when inside the space. This cannot be changed by the update operation. + +`name`:: (string) Required display name for the space. + +`description`:: (string) Optional description for the space. + +`initials`:: (string) Optionally specify the initials shown in the Space Avatar for this space. By default, the initials will be automatically generated from the space name. +If specified, initials should be either 1 or 2 characters. + +`color`:: (string) Optioanlly specify the hex color code used in the Space Avatar for this space. By default, the color will be automatically generated from the space name. + +===== Example + +[source,js] +-------------------------------------------------- +PUT /api/spaces/space/marketing +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- +// KIBANA + +==== Response + +A successful call returns a response code of `200` with the updated Space. diff --git a/docs/api/spaces.asciidoc b/docs/api/spaces.asciidoc new file mode 100644 index 00000000000000..ea66d50d396b99 --- /dev/null +++ b/docs/api/spaces.asciidoc @@ -0,0 +1,17 @@ +[role="xpack"] +[[spaces-api]] +== Kibana Spaces API + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +The spaces API allows people to manage their spaces within {kib}. + +* <> +* <> +* <> +* <> + +include::spaces-management/put.asciidoc[] +include::spaces-management/post.asciidoc[] +include::spaces-management/get.asciidoc[] +include::spaces-management/delete.asciidoc[] diff --git a/x-pack/plugins/spaces/common/is_reserved_space.ts b/x-pack/plugins/spaces/common/is_reserved_space.ts index 0889686aa77f59..40acd7630b66c9 100644 --- a/x-pack/plugins/spaces/common/is_reserved_space.ts +++ b/x-pack/plugins/spaces/common/is_reserved_space.ts @@ -13,6 +13,6 @@ import { Space } from './model/space'; * @param space the space * @returns boolean */ -export function isReservedSpace(space: Space): boolean { +export function isReservedSpace(space: Space | null): boolean { return get(space, '_reserved', false); } diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js index f03e8bde19afa6..3eafab6461f9e0 100644 --- a/x-pack/plugins/spaces/index.js +++ b/x-pack/plugins/spaces/index.js @@ -7,7 +7,8 @@ import { resolve } from 'path'; import { validateConfig } from './server/lib/validate_config'; import { checkLicense } from './server/lib/check_license'; -import { initSpacesApi } from './server/routes/api/v1/spaces'; +import { initPublicSpacesApi } from './server/routes/api/public'; +import { initPrivateApis } from './server/routes/api/v1'; import { initSpacesRequestInterceptors } from './server/lib/space_request_interceptors'; import { createDefaultSpace } from './server/lib/create_default_space'; import { createSpacesService } from './server/lib/create_spaces_service'; @@ -93,7 +94,8 @@ export const spaces = (kibana) => new kibana.Plugin({ spacesSavedObjectsClientWrapperFactory(spacesService) ); - initSpacesApi(server); + initPrivateApis(server); + initPublicSpacesApi(server); initSpacesRequestInterceptors(server); diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.ts b/x-pack/plugins/spaces/public/lib/spaces_manager.ts index a6b21f2fa5229f..53835ab122f87e 100644 --- a/x-pack/plugins/spaces/public/lib/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/lib/spaces_manager.ts @@ -16,12 +16,12 @@ export class SpacesManager extends EventEmitter { constructor(httpAgent: any, chrome: any) { super(); this.httpAgent = httpAgent; - this.baseUrl = chrome.addBasePath(`/api/spaces/v1`); + this.baseUrl = chrome.addBasePath(`/api/spaces`); } public async getSpaces(): Promise { return await this.httpAgent - .get(`${this.baseUrl}/spaces`) + .get(`${this.baseUrl}/space`) .then((response: IHttpResponse) => response.data); } @@ -43,7 +43,7 @@ export class SpacesManager extends EventEmitter { public async changeSelectedSpace(space: Space) { return await this.httpAgent - .post(`${this.baseUrl}/space/${space.id}/select`) + .post(`${this.baseUrl}/v1/space/${space.id}/select`) .then((response: IHttpResponse) => { if (response.data && response.data.location) { window.location = response.data.location; diff --git a/x-pack/plugins/spaces/server/lib/errors.js b/x-pack/plugins/spaces/server/lib/errors.ts similarity index 85% rename from x-pack/plugins/spaces/server/lib/errors.js rename to x-pack/plugins/spaces/server/lib/errors.ts index 6996faeaac8de6..4f95c175b0f159 100644 --- a/x-pack/plugins/spaces/server/lib/errors.js +++ b/x-pack/plugins/spaces/server/lib/errors.ts @@ -3,9 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +// @ts-ignore import { wrap as wrapBoom } from 'boom'; -export function wrapError(error) { +export function wrapError(error: any) { return wrapBoom(error, error.status); } diff --git a/x-pack/plugins/spaces/server/lib/route_pre_check_license.js b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts similarity index 80% rename from x-pack/plugins/spaces/server/lib/route_pre_check_license.js rename to x-pack/plugins/spaces/server/lib/route_pre_check_license.ts index 891e9fc4125a9b..449836633993c4 100644 --- a/x-pack/plugins/spaces/server/lib/route_pre_check_license.js +++ b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -const Boom = require('boom'); +import Boom from 'boom'; -export function routePreCheckLicense(server) { +export function routePreCheckLicense(server: any) { const xpackMainPlugin = server.plugins.xpack_main; const pluginId = 'spaces'; - return function forbidApiAccess(request, reply) { + return function forbidApiAccess(request: any, reply: any) { const licenseCheckResults = xpackMainPlugin.info.feature(pluginId).getLicenseCheckResults(); if (!licenseCheckResults.showSpaces) { reply(Boom.forbidden(licenseCheckResults.linksMessage)); diff --git a/x-pack/plugins/spaces/server/lib/space_schema.js b/x-pack/plugins/spaces/server/lib/space_schema.ts similarity index 96% rename from x-pack/plugins/spaces/server/lib/space_schema.js rename to x-pack/plugins/spaces/server/lib/space_schema.ts index dc60c10a36bc2b..043856235acba1 100644 --- a/x-pack/plugins/spaces/server/lib/space_schema.js +++ b/x-pack/plugins/spaces/server/lib/space_schema.ts @@ -13,5 +13,5 @@ export const spaceSchema = Joi.object({ description: Joi.string(), initials: Joi.string().max(MAX_SPACE_INITIALS), color: Joi.string().regex(/^#[a-z0-9]{6}$/, `6 digit hex color, starting with a #`), - _reserved: Joi.boolean() + _reserved: Joi.boolean(), }).default(); diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.js b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts similarity index 80% rename from x-pack/plugins/spaces/server/lib/spaces_url_parser.js rename to x-pack/plugins/spaces/server/lib/spaces_url_parser.ts index 397863785e86c7..14113cbf9d8070 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_url_parser.js +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts @@ -5,8 +5,11 @@ */ import { DEFAULT_SPACE_ID } from '../../common/constants'; -export function getSpaceIdFromPath(requestBasePath = '/', serverBasePath = '/') { - let pathToCheck = requestBasePath; +export function getSpaceIdFromPath( + requestBasePath: string = '/', + serverBasePath: string = '/' +): string { + let pathToCheck: string = requestBasePath; if (serverBasePath && serverBasePath !== '/' && requestBasePath.startsWith(serverBasePath)) { pathToCheck = requestBasePath.substr(serverBasePath.length); @@ -28,7 +31,11 @@ export function getSpaceIdFromPath(requestBasePath = '/', serverBasePath = '/') return spaceId; } -export function addSpaceIdToPath(basePath = '/', spaceId = '', requestedPath = '') { +export function addSpaceIdToPath( + basePath: string = '/', + spaceId: string = '', + requestedPath: string = '' +): string { if (requestedPath && !requestedPath.startsWith('/')) { throw new Error(`path must start with a /`); } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts new file mode 100644 index 00000000000000..85284e3fc3a1c0 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +export function createSpaces() { + return [ + { + id: 'a-space', + attributes: { + name: 'a space', + }, + }, + { + id: 'b-space', + attributes: { + name: 'b space', + }, + }, + { + id: 'default', + attributes: { + name: 'Default Space', + _reserved: true, + }, + }, + ]; +} diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts new file mode 100644 index 00000000000000..a184f21076d4f6 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts @@ -0,0 +1,142 @@ +/* + * 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. + */ + +// @ts-ignore +import { Server } from 'hapi'; +import { createSpaces } from './create_spaces'; + +export interface TestConfig { + [configKey: string]: any; +} + +export interface TestOptions { + setupFn?: (server: any) => void; + testConfig?: TestConfig; + payload?: any; + preCheckLicenseImpl?: (req: any, reply: any) => any; +} + +export type TeardownFn = () => void; + +export interface RequestRunnerResult { + server: any; + mockSavedObjectsClient: any; + response: any; +} + +export type RequestRunner = ( + method: string, + path: string, + options?: TestOptions +) => Promise; + +export const defaultPreCheckLicenseImpl = (request: any, reply: any) => reply(); + +const baseConfig: TestConfig = { + 'server.basePath': '', +}; + +export function createTestHandler(initApiFn: (server: any, preCheckLicenseImpl: any) => void) { + const teardowns: TeardownFn[] = []; + + const spaces = createSpaces(); + + const request: RequestRunner = async ( + method: string, + path: string, + options: TestOptions = {} + ) => { + const { + setupFn = () => { + return; + }, + testConfig = {}, + payload, + preCheckLicenseImpl = defaultPreCheckLicenseImpl, + } = options; + + let pre = jest.fn(); + if (preCheckLicenseImpl) { + pre = pre.mockImplementation(preCheckLicenseImpl); + } + + const server = new Server(); + + const config = { + ...baseConfig, + ...testConfig, + }; + + server.connection({ port: 0 }); + + await setupFn(server); + + server.decorate( + 'server', + 'config', + jest.fn(() => { + return { + get: (key: string) => config[key], + }; + }) + ); + + initApiFn(server, pre); + + server.decorate('request', 'getBasePath', jest.fn()); + server.decorate('request', 'setBasePath', jest.fn()); + + // Mock server.getSavedObjectsClient() + const mockSavedObjectsClient = { + get: jest.fn((type, id) => { + return spaces.filter(s => s.id === id)[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn(() => ({})), + update: jest.fn(() => ({})), + delete: jest.fn(), + errors: { + isNotFoundError: jest.fn(() => true), + }, + }; + + server.decorate('request', 'getSavedObjectsClient', () => mockSavedObjectsClient); + + teardowns.push(() => server.stop()); + + const testRun = async () => { + const response = await server.inject({ + method, + url: path, + payload, + }); + + if (preCheckLicenseImpl) { + expect(pre).toHaveBeenCalled(); + } else { + expect(pre).not.toHaveBeenCalled(); + } + + return response; + }; + + return { + server, + mockSavedObjectsClient, + response: await testRun(), + }; + }; + + return { + request, + teardowns, + }; +} diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts new file mode 100644 index 00000000000000..37fe32c80032e5 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts @@ -0,0 +1,15 @@ +/* + * 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. + */ + +export { createSpaces } from './create_spaces'; +export { + createTestHandler, + TestConfig, + TestOptions, + TeardownFn, + RequestRunner, + RequestRunnerResult, +} from './create_test_handler'; diff --git a/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts new file mode 100644 index 00000000000000..523b5a2cb2e7b0 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts @@ -0,0 +1,85 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initDeleteSpacesApi } from './delete'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initDeleteSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`'DELETE spaces/{id}' deletes the space`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(204); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/a-space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('DELETE spaces/{id} pretends to delete a non-existent space', async () => { + const { response } = await request('DELETE', '/api/spaces/space/not-a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(204); + }); + + test(`'DELETE spaces/{id}' cannot delete reserved spaces`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/default'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(payload)).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: 'This Space cannot be deleted because it is reserved.', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/delete.ts b/x-pack/plugins/spaces/server/routes/api/public/delete.ts new file mode 100644 index 00000000000000..9937b786ccfa71 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/delete.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ + +import Boom from 'boom'; +import { isReservedSpace } from '../../../../common/is_reserved_space'; +import { wrapError } from '../../../lib/errors'; +import { getSpaceById } from '../../lib'; + +export function initDeleteSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'DELETE', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const client = request.getSavedObjectsClient(); + + const id = request.params.id; + + let result; + + try { + const existingSpace = await getSpaceById(client, id); + if (isReservedSpace(existingSpace)) { + return reply( + wrapError(Boom.badRequest('This Space cannot be deleted because it is reserved.')) + ); + } + + result = await client.delete('space', id); + } catch (error) { + return reply(wrapError(error)); + } + + return reply(result).code(204); + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/get.test.ts b/x-pack/plugins/spaces/server/routes/api/public/get.test.ts new file mode 100644 index 00000000000000..4d04759b283a8b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/get.test.ts @@ -0,0 +1,86 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { createSpaces, createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initGetSpacesApi } from './get'; + +describe('GET spaces', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + const spaces = createSpaces(); + + beforeEach(() => { + const setup = createTestHandler(initGetSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`'GET spaces' returns all available spaces`, async () => { + const { response } = await request('GET', '/api/spaces/space'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpaces: Space[] = JSON.parse(payload); + expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('GET', '/api/spaces/space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test(`'GET spaces/{id}' returns the space with that id`, async () => { + const { response } = await request('GET', '/api/spaces/space/default'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpace = JSON.parse(payload); + expect(resultSpace.id).toEqual('default'); + }); + + test(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => { + const { response } = await request('GET', '/api/spaces/space/not-a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/get.ts b/x-pack/plugins/spaces/server/routes/api/public/get.ts new file mode 100644 index 00000000000000..95f1d273a8f6b2 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/get.ts @@ -0,0 +1,61 @@ +/* + * 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. + */ + +import Boom from 'boom'; +import { wrapError } from '../../../lib/errors'; +import { convertSavedObjectToSpace } from '../../lib'; + +export function initGetSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'GET', + path: '/api/spaces/space', + async handler(request: any, reply: any) { + const client = request.getSavedObjectsClient(); + + let spaces; + + try { + const result = await client.find({ + type: 'space', + sortField: 'name.keyword', + }); + + spaces = result.saved_objects.map(convertSavedObjectToSpace); + } catch (error) { + return reply(wrapError(error)); + } + + return reply(spaces); + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); + + server.route({ + method: 'GET', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const spaceId = request.params.id; + + const client = request.getSavedObjectsClient(); + + try { + const response = await client.get('space', spaceId); + + return reply(convertSavedObjectToSpace(response)); + } catch (error) { + if (client.errors.isNotFoundError(error)) { + return reply(Boom.notFound()); + } + return reply(wrapError(error)); + } + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/index.ts b/x-pack/plugins/spaces/server/routes/api/public/index.ts new file mode 100644 index 00000000000000..602b62ab26d06f --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; +import { initDeleteSpacesApi } from './delete'; +import { initGetSpacesApi } from './get'; +import { initPostSpacesApi } from './post'; +import { initPutSpacesApi } from './put'; + +export function initPublicSpacesApi(server: any) { + const routePreCheckLicenseFn = routePreCheckLicense(server); + + initDeleteSpacesApi(server, routePreCheckLicenseFn); + initGetSpacesApi(server, routePreCheckLicenseFn); + initPostSpacesApi(server, routePreCheckLicenseFn); + initPutSpacesApi(server, routePreCheckLicenseFn); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/post.test.ts b/x-pack/plugins/spaces/server/routes/api/public/post.test.ts new file mode 100644 index 00000000000000..f97931d36ed664 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/post.test.ts @@ -0,0 +1,106 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); + +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPostSpacesApi } from './post'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPostSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('POST /space should create a new space with the provided ID', async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const { mockSavedObjectsClient, response } = await request('POST', '/api/spaces/space', { + payload, + }); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description' }, + { id: 'my-space-id', overwrite: false } + ); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const { response } = await request('POST', '/api/spaces/space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('POST /space should not allow a space to be updated', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { response } = await request('POST', '/api/spaces/space', { payload }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(409); + expect(JSON.parse(responsePayload)).toEqual({ + error: 'Conflict', + message: + 'A space with the identifier a-space already exists. Please choose a different identifier', + statusCode: 409, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/post.ts b/x-pack/plugins/spaces/server/routes/api/public/post.ts new file mode 100644 index 00000000000000..fd51390af50230 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/post.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +import Boom from 'boom'; +import { omit } from 'lodash'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { getSpaceById } from '../../lib'; + +export function initPostSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'POST', + path: '/api/spaces/space', + async handler(request: any, reply: any) { + const client = request.getSavedObjectsClient(); + + const space = omit(request.payload, ['id', '_reserved']); + + const id = request.payload.id; + + const existingSpace = await getSpaceById(client, id); + if (existingSpace) { + return reply( + Boom.conflict( + `A space with the identifier ${id} already exists. Please choose a different identifier` + ) + ); + } + + try { + return reply(await client.create('space', { ...space }, { id, overwrite: false })); + } catch (error) { + return reply(wrapError(error)); + } + }, + config: { + validate: { + payload: spaceSchema, + }, + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/put.test.ts b/x-pack/plugins/spaces/server/routes/api/public/put.test.ts new file mode 100644 index 00000000000000..2af4fc9bbeaf3b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/put.test.ts @@ -0,0 +1,97 @@ +/* + * 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. + */ +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPutSpacesApi } from './put'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPutSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('PUT /space should update an existing space with the provided ID', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { mockSavedObjectsClient, response } = await request('PUT', '/api/spaces/space/a-space', { + payload, + }); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsClient.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: 'with a description', + }); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { response } = await request('PUT', '/api/spaces/space/a-space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('PUT /space should not allow a new space to be created', async () => { + const payload = { + id: 'a-new-space', + name: 'my new space', + description: 'with a description', + }; + + const { response } = await request('PUT', '/api/spaces/space/a-new-space', { payload }); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/put.ts b/x-pack/plugins/spaces/server/routes/api/public/put.ts new file mode 100644 index 00000000000000..093d7c777e7864 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/put.ts @@ -0,0 +1,49 @@ +/* + * 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. + */ + +import Boom from 'boom'; +import { omit } from 'lodash'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { convertSavedObjectToSpace, getSpaceById } from '../../lib'; + +export function initPutSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'PUT', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const client = request.getSavedObjectsClient(); + + const space: Space = omit(request.payload, ['id']); + const id = request.params.id; + + const existingSpace = await getSpaceById(client, id); + + if (existingSpace) { + space._reserved = existingSpace._reserved; + } else { + return reply(Boom.notFound(`Unable to find space with ID ${id}`)); + } + + let result; + try { + result = await client.update('space', id, { ...space }); + } catch (error) { + return reply(wrapError(error)); + } + + const updatedSpace = convertSavedObjectToSpace(result); + return reply(updatedSpace); + }, + config: { + validate: { + payload: spaceSchema, + }, + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/index.ts b/x-pack/plugins/spaces/server/routes/api/v1/index.ts new file mode 100644 index 00000000000000..75659c14c03aee --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; +import { initPrivateSpacesApi } from './spaces'; + +export function initPrivateApis(server: any) { + const routePreCheckLicenseFn = routePreCheckLicense(server); + initPrivateSpacesApi(server, routePreCheckLicenseFn); +} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js deleted file mode 100644 index 5d250c1c92c07a..00000000000000 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ /dev/null @@ -1,202 +0,0 @@ -/* - * 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. - */ - -import Boom from 'boom'; -import { omit } from 'lodash'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { spaceSchema } from '../../../lib/space_schema'; -import { wrapError } from '../../../lib/errors'; -import { isReservedSpace } from '../../../../common/is_reserved_space'; -import { addSpaceIdToPath } from '../../../lib/spaces_url_parser'; - -export function initSpacesApi(server) { - const routePreCheckLicenseFn = routePreCheckLicense(server); - - function convertSavedObjectToSpace(savedObject) { - return { - id: savedObject.id, - ...savedObject.attributes - }; - } - - server.route({ - method: 'GET', - path: '/api/spaces/v1/spaces', - async handler(request, reply) { - const client = request.getSavedObjectsClient(); - - let spaces; - - try { - const result = await client.find({ - type: 'space', - sortField: 'name.keyword', - }); - - spaces = result.saved_objects.map(convertSavedObjectToSpace); - } catch (error) { - return reply(wrapError(error)); - } - - return reply(spaces); - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'GET', - path: '/api/spaces/v1/space/{id}', - async handler(request, reply) { - const spaceId = request.params.id; - - const client = request.getSavedObjectsClient(); - - try { - const response = await client.get('space', spaceId); - - return reply(convertSavedObjectToSpace(response)); - } catch (error) { - return reply(wrapError(error)); - } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'POST', - path: '/api/spaces/v1/space', - async handler(request, reply) { - const client = request.getSavedObjectsClient(); - - const space = omit(request.payload, ['id', '_reserved']); - - const id = request.payload.id; - - const existingSpace = await getSpaceById(client, id); - if (existingSpace) { - return reply(Boom.conflict(`A space with the identifier ${id} already exists. Please choose a different identifier`)); - } - - try { - return reply(await client.create('space', { ...space }, { id, overwrite: false })); - } catch (error) { - return reply(wrapError(error)); - } - - }, - config: { - validate: { - payload: spaceSchema - }, - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'PUT', - path: '/api/spaces/v1/space/{id}', - async handler(request, reply) { - const client = request.getSavedObjectsClient(); - - const space = omit(request.payload, ['id']); - const id = request.params.id; - - const existingSpace = await getSpaceById(client, id); - - if (existingSpace) { - space._reserved = existingSpace._reserved; - } else { - return reply(Boom.notFound(`Unable to find space with ID ${id}`)); - } - - let result; - try { - result = await client.update('space', id, { ...space }); - } catch (error) { - return reply(wrapError(error)); - } - - const updatedSpace = convertSavedObjectToSpace(result); - return reply(updatedSpace); - }, - config: { - validate: { - payload: spaceSchema - }, - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'DELETE', - path: '/api/spaces/v1/space/{id}', - async handler(request, reply) { - const client = request.getSavedObjectsClient(); - - const id = request.params.id; - - let result; - - try { - const existingSpace = await getSpaceById(client, id); - if (isReservedSpace(existingSpace)) { - return reply(wrapError(Boom.badRequest('This Space cannot be deleted because it is reserved.'))); - } - - result = await client.delete('space', id); - } catch (error) { - return reply(wrapError(error)); - } - - return reply(result).code(204); - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); - - server.route({ - method: 'POST', - path: '/api/spaces/v1/space/{id}/select', - async handler(request, reply) { - const client = request.getSavedObjectsClient(); - - const id = request.params.id; - - try { - const existingSpace = await getSpaceById(client, id); - - const config = server.config(); - - return reply({ - location: addSpaceIdToPath(config.get('server.basePath'), existingSpace.id, config.get('server.defaultRoute')) - }); - - } catch (error) { - return reply(wrapError(error)); - } - } - }); - - async function getSpaceById(client, spaceId) { - try { - const existingSpace = await client.get('space', spaceId); - return { - id: existingSpace.id, - ...existingSpace.attributes - }; - } catch (error) { - if (client.errors.isNotFoundError(error)) { - return null; - } - throw error; - } - } -} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js deleted file mode 100644 index fb957e4586343a..00000000000000 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js +++ /dev/null @@ -1,281 +0,0 @@ -/* - * 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. - */ - -import { Server } from 'hapi'; -import { initSpacesApi } from './spaces'; - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request, reply) => reply.continue() - }; -}); - -jest.mock('../../../../../../server/lib/get_client_shield', () => { - return { - getClient: () => { - return { - callWithInternalUser: jest.fn(() => { }) - }; - } - }; -}); - -const spaces = [{ - id: 'a-space', - attributes: { - name: 'a space', - } -}, { - id: 'b-space', - attributes: { - name: 'b space', - } -}, { - id: 'default', - attributes: { - name: 'Default Space', - _reserved: true - } -}]; - -describe('Spaces API', () => { - const teardowns = []; - let request; - - const baseConfig = { - 'server.basePath': '' - }; - - beforeEach(() => { - request = async (method, path, options = {}) => { - - const { - setupFn = () => { }, - testConfig = {}, - payload, - } = options; - - const server = new Server(); - - const config = { - ...baseConfig, - ...testConfig - }; - - server.connection({ port: 0 }); - - await setupFn(server); - - server.decorate('server', 'config', jest.fn(() => { - return { - get: (key) => config[key] - }; - })); - - initSpacesApi(server); - - server.decorate('request', 'getBasePath', jest.fn()); - server.decorate('request', 'setBasePath', jest.fn()); - - // Mock server.getSavedObjectsClient() - const mockSavedObjectsClient = { - get: jest.fn((type, id) => { - return spaces.filter(s => s.id === id)[0]; - }), - find: jest.fn(() => { - return { - total: spaces.length, - saved_objects: spaces - }; - }), - create: jest.fn(() => ({})), - update: jest.fn(() => ({})), - delete: jest.fn(), - errors: { - isNotFoundError: jest.fn(() => true) - } - }; - - server.decorate('request', 'getSavedObjectsClient', () => mockSavedObjectsClient); - - teardowns.push(() => server.stop()); - - return { - server, - mockSavedObjectsClient, - response: await server.inject({ - method, - url: path, - payload, - }) - }; - }; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`'GET spaces' returns all available spaces`, async () => { - const { response } = await request('GET', '/api/spaces/v1/spaces'); - - const { - statusCode, - payload - } = response; - - expect(statusCode).toEqual(200); - const resultSpaces = JSON.parse(payload); - expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); - }); - - test(`'GET spaces/{id}' returns the space with that id`, async () => { - const { response } = await request('GET', '/api/spaces/v1/space/default'); - - const { - statusCode, - payload - } = response; - - expect(statusCode).toEqual(200); - const resultSpace = JSON.parse(payload); - expect(resultSpace.id).toEqual('default'); - }); - - test(`'DELETE spaces/{id}' deletes the space`, async () => { - const { response } = await request('DELETE', '/api/spaces/v1/space/a-space'); - - const { - statusCode - } = response; - - expect(statusCode).toEqual(204); - }); - - test(`'DELETE spaces/{id}' cannot delete reserved spaces`, async () => { - const { response } = await request('DELETE', '/api/spaces/v1/space/default'); - - const { - statusCode, - payload - } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(payload)).toEqual({ - statusCode: 400, - error: "Bad Request", - message: "This Space cannot be deleted because it is reserved." - }); - }); - - test('POST /space should create a new space with the provided ID', async () => { - const payload = { - id: 'my-space-id', - name: 'my new space', - description: 'with a description', - }; - - const { mockSavedObjectsClient, response } = await request('POST', '/api/spaces/v1/space', { payload }); - - const { - statusCode, - } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsClient.create) - .toHaveBeenCalledWith('space', { name: 'my new space', description: 'with a description' }, { id: 'my-space-id', overwrite: false }); - }); - - test('POST /space should not allow a space to be updated', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: 'with a description', - }; - - const { response } = await request('POST', '/api/spaces/v1/space', { payload }); - - const { - statusCode, - payload: responsePayload, - } = response; - - expect(statusCode).toEqual(409); - expect(JSON.parse(responsePayload)).toEqual({ - error: 'Conflict', - message: "A space with the identifier a-space already exists. Please choose a different identifier", - statusCode: 409 - }); - }); - - test('PUT /space should update an existing space with the provided ID', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: 'with a description', - }; - - const { mockSavedObjectsClient, response } = await request('PUT', '/api/spaces/v1/space/a-space', { payload }); - - const { - statusCode, - } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsClient.update) - .toHaveBeenCalledWith('space', 'a-space', { name: 'my updated space', description: 'with a description' }); - }); - - test('PUT /space should not allow a new space to be created', async () => { - const payload = { - id: 'a-new-space', - name: 'my new space', - description: 'with a description', - }; - - const { response } = await request('PUT', '/api/spaces/v1/space/a-new-space', { payload }); - - const { - statusCode, - } = response; - - expect(statusCode).toEqual(404); - }); - - test('POST space/{id}/select should respond with the new space location', async () => { - const { response } = await request('POST', '/api/spaces/v1/space/a-space/select'); - - const { - statusCode, - payload - } = response; - - expect(statusCode).toEqual(200); - - const result = JSON.parse(payload); - expect(result.location).toEqual('/s/a-space'); - }); - - test('POST space/{id}/select should respond with the new space location when a server.basePath is in use', async () => { - const testConfig = { - 'server.basePath': '/my/base/path' - }; - - const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { testConfig }); - - const { - statusCode, - payload - } = response; - - expect(statusCode).toEqual(200); - - const result = JSON.parse(payload); - expect(result.location).toEqual('/my/base/path/s/a-space'); - }); -}); diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts new file mode 100644 index 00000000000000..bbdab1be349101 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts @@ -0,0 +1,93 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); + +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPrivateSpacesApi } from './spaces'; + +describe('Spaces API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPrivateSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('POST space/{id}/select should respond with the new space location', async () => { + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/s/a-space'); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('POST space/{id}/select should respond with 404 when the space is not found', async () => { + const { response } = await request('POST', '/api/spaces/v1/space/not-a-space/select'); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); + + test('POST space/{id}/select should respond with the new space location when a server.basePath is in use', async () => { + const testConfig = { + 'server.basePath': '/my/base/path', + }; + + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { + testConfig, + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/my/base/path/s/a-space'); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.ts b/x-pack/plugins/spaces/server/routes/api/v1/spaces.ts new file mode 100644 index 00000000000000..0233cb76b96d85 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.ts @@ -0,0 +1,45 @@ +/* + * 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. + */ + +import Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { addSpaceIdToPath } from '../../../lib/spaces_url_parser'; +import { getSpaceById } from '../../lib'; + +export function initPrivateSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'POST', + path: '/api/spaces/v1/space/{id}/select', + async handler(request: any, reply: any) { + const client = request.getSavedObjectsClient(); + + const id = request.params.id; + + try { + const existingSpace: Space | null = await getSpaceById(client, id); + if (!existingSpace) { + return reply(Boom.notFound()); + } + + const config = server.config(); + + return reply({ + location: addSpaceIdToPath( + config.get('server.basePath'), + existingSpace.id, + config.get('server.defaultRoute') + ), + }); + } catch (error) { + return reply(wrapError(error)); + } + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts new file mode 100644 index 00000000000000..31738ff5628658 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +import { convertSavedObjectToSpace } from './convert_saved_object_to_space'; + +describe('convertSavedObjectToSpace', () => { + it('converts a saved object representation to a Space object', () => { + const savedObject = { + id: 'foo', + attributes: { + name: 'Foo Space', + description: 'no fighting', + _reserved: false, + }, + }; + + expect(convertSavedObjectToSpace(savedObject)).toEqual({ + id: 'foo', + name: 'Foo Space', + description: 'no fighting', + _reserved: false, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts new file mode 100644 index 00000000000000..d3ee173a2e80fe --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +import { Space } from '../../../common/model/space'; + +/* + * 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. + */ + +export function convertSavedObjectToSpace(savedObject: any): Space { + return { + id: savedObject.id, + ...savedObject.attributes, + }; +} diff --git a/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts b/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts new file mode 100644 index 00000000000000..4143c09a79a930 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ +import { Space } from '../../../common/model/space'; +import { convertSavedObjectToSpace } from './convert_saved_object_to_space'; + +export async function getSpaceById(client: any, spaceId: string): Promise { + try { + const existingSpace = await client.get('space', spaceId); + return convertSavedObjectToSpace(existingSpace); + } catch (error) { + if (client.errors.isNotFoundError(error)) { + return null; + } + throw error; + } +} diff --git a/x-pack/plugins/spaces/server/routes/lib/index.ts b/x-pack/plugins/spaces/server/routes/lib/index.ts new file mode 100644 index 00000000000000..af673887925658 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { convertSavedObjectToSpace } from './convert_saved_object_to_space'; +export { getSpaceById } from './get_space_by_id';