diff --git a/x-pack/legacy/plugins/spaces/index.ts b/x-pack/legacy/plugins/spaces/index.ts index b56fe3e5e618a..5cce8f9a79989 100644 --- a/x-pack/legacy/plugins/spaces/index.ts +++ b/x-pack/legacy/plugins/spaces/index.ts @@ -4,21 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Rx from 'rxjs'; import { resolve } from 'path'; import KbnServer, { Server } from 'src/legacy/server/kbn_server'; -import { CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { Legacy } from 'kibana'; +import { SpacesServiceSetup } from '../../../plugins/spaces/server/spaces_service/spaces_service'; +import { SpacesPluginSetup } from '../../../plugins/spaces/server'; import { createOptionalPlugin } from '../../server/lib/optional_plugin'; // @ts-ignore import { AuditLogger } from '../../server/lib/audit_logger'; import mappings from './mappings.json'; import { wrapError } from './server/lib/errors'; -import { getActiveSpace } from './server/lib/get_active_space'; import { getSpaceSelectorUrl } from './server/lib/get_space_selector_url'; import { migrateToKibana660 } from './server/lib/migrations'; -import { plugin } from './server/new_platform'; import { SecurityPlugin } from '../security'; -import { SpacesServiceSetup } from './server/new_platform/spaces_service/spaces_service'; import { initSpaceSelectorView } from './server/routes/views'; export interface SpacesPlugin { @@ -92,18 +90,19 @@ export const spaces = (kibana: Record) => }, async replaceInjectedVars( vars: Record, - request: Record, + request: Legacy.Request, server: Record ) { - const spacesClient = await server.plugins.spaces.getScopedSpacesClient(request); + const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup; + if (!spacesPlugin) { + throw new Error('New Platform XPack Spaces plugin is not available.'); + } + const spaceId = spacesPlugin.spacesService.getSpaceId(request); + const spacesClient = await spacesPlugin.spacesService.scopedClient(request); try { vars.activeSpace = { valid: true, - space: await getActiveSpace( - spacesClient, - request.getBasePath(), - server.config().get('server.basePath') - ), + space: await spacesClient.get(spaceId), }; } catch (e) { vars.activeSpace = { @@ -118,49 +117,15 @@ export const spaces = (kibana: Record) => async init(server: Server) { const kbnServer = (server as unknown) as KbnServer; - const initializerContext = { - config: { - create: () => { - return Rx.of({ - maxSpaces: server.config().get('xpack.spaces.maxSpaces'), - }); - }, - }, - logger: { - get(...contextParts: string[]) { - return kbnServer.newPlatform.coreContext.logger.get( - 'plugins', - 'spaces', - ...contextParts - ); - }, - }, - } as PluginInitializerContext; - - const core = (kbnServer.newPlatform.setup.core as unknown) as CoreSetup; - - const plugins = { - xpackMain: server.plugins.xpack_main, - // TODO: Spaces has a circular dependency with Security right now. - // Security is not yet available when init runs, so this is wrapped in an optional function for the time being. - security: createOptionalPlugin( - server.config(), - 'xpack.security', - server.plugins, - 'security' - ), - spaces: this, - }; - const { spacesService, registerLegacyAPI } = await plugin(initializerContext).setup( - core, - plugins - ); + const spacesPlugin = kbnServer.newPlatform.setup.plugins.spaces as SpacesPluginSetup; + if (!spacesPlugin) { + throw new Error('New Platform XPack Spaces plugin is not available.'); + } const config = server.config(); - registerLegacyAPI({ - router: server.route.bind(server), + spacesPlugin.registerLegacyAPI({ legacyConfig: { serverBasePath: config.get('server.basePath'), serverDefaultRoute: config.get('server.defaultRoute'), @@ -178,14 +143,21 @@ export const spaces = (kibana: Record) => create: (pluginId: string) => new AuditLogger(server, pluginId, server.config(), server.plugins.xpack_main.info), }, + security: createOptionalPlugin( + server.config(), + 'xpack.security', + server.plugins, + 'security' + ), + xpackMain: server.plugins.xpack_main, }); initSpaceSelectorView(server); - server.expose('getSpaceId', (request: any) => spacesService.getSpaceId(request)); - server.expose('spaceIdToNamespace', spacesService.spaceIdToNamespace); - server.expose('namespaceToSpaceId', spacesService.namespaceToSpaceId); - server.expose('getBasePath', spacesService.getBasePath); - server.expose('getScopedSpacesClient', spacesService.scopedClient); + server.expose('getSpaceId', (request: any) => spacesPlugin.spacesService.getSpaceId(request)); + server.expose('spaceIdToNamespace', spacesPlugin.spacesService.spaceIdToNamespace); + server.expose('namespaceToSpaceId', spacesPlugin.spacesService.namespaceToSpaceId); + server.expose('getBasePath', spacesPlugin.spacesService.getBasePath); + server.expose('getScopedSpacesClient', spacesPlugin.spacesService.scopedClient); }, }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts b/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts index 907b7b164b69b..e32018a0225ef 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts +++ b/x-pack/legacy/plugins/spaces/server/lib/get_active_space.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SpacesClient } from '../../../../../plugins/spaces/server/lib/spaces_client'; import { Space } from '../../common/model/space'; import { wrapError } from './errors'; -import { SpacesClient } from './spaces_client'; import { getSpaceIdFromPath } from './spaces_url_parser'; +// TODO: ML is relying on this for some reason. Otherwise this can be removed altogether. export async function getActiveSpace( spacesClient: SpacesClient, requestBasePath: string, diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts deleted file mode 100644 index d84c79c3d78b7..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts +++ /dev/null @@ -1,307 +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 * as Rx from 'rxjs'; -import { Server } from 'hapi'; -import { Legacy } from 'kibana'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { elasticsearchServiceMock, coreMock } from 'src/core/server/mocks'; -import { SavedObjectsSchema, SavedObjectsService } from 'src/core/server'; -import { Readable } from 'stream'; -import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; -import { createOptionalPlugin } from '../../../../../../server/lib/optional_plugin'; -import { SpacesClient } from '../../../lib/spaces_client'; -import { createSpaces } from './create_spaces'; -import { ExternalRouteDeps } from '../external'; -import { SpacesService } from '../../../new_platform/spaces_service'; -import { SpacesAuditLogger } from '../../../lib/audit_logger'; -import { InternalRouteDeps } from '../v1'; -import { LegacyAPI } from '../../../new_platform/plugin'; - -interface KibanaServer extends Legacy.Server { - savedObjects: any; -} - -export interface TestConfig { - [configKey: string]: any; -} - -export interface TestOptions { - setupFn?: (server: any) => void; - testConfig?: TestConfig; - payload?: any; - preCheckLicenseImpl?: (req: any, h: any) => any; - expectSpacesClientCall?: boolean; - expectPreCheckLicenseCall?: boolean; -} - -export type TeardownFn = () => void; - -export interface RequestRunnerResult { - server: any; - mockSavedObjectsRepository: any; - mockSavedObjectsService: { - getScopedSavedObjectsClient: jest.Mock; - importExport: { - getSortedObjectsForExport: jest.Mock< - SavedObjectsService['importExport']['getSortedObjectsForExport'] - >; - importSavedObjects: jest.Mock; - resolveImportErrors: jest.Mock; - }; - }; - headers: Record; - response: any; -} - -export type RequestRunner = ( - method: string, - path: string, - options?: TestOptions -) => Promise; - -export const defaultPreCheckLicenseImpl = (request: any) => ''; - -const baseConfig: TestConfig = { - 'server.basePath': '', -}; - -async function readStreamToCompletion(stream: Readable) { - return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[]; -} - -export function createTestHandler( - initApiFn: (deps: ExternalRouteDeps & InternalRouteDeps) => void -) { - const teardowns: TeardownFn[] = []; - - const spaces = createSpaces(); - - const request: RequestRunner = async ( - method: string, - path: string, - options: TestOptions = {} - ) => { - const { - setupFn = () => { - return; - }, - testConfig = {}, - payload, - preCheckLicenseImpl = defaultPreCheckLicenseImpl, - expectPreCheckLicenseCall = true, - expectSpacesClientCall = true, - } = options; - - let pre = jest.fn(); - if (preCheckLicenseImpl) { - pre = pre.mockImplementation(preCheckLicenseImpl); - } - - const server = new Server() as KibanaServer; - - const config = { - ...baseConfig, - ...testConfig, - }; - - await setupFn(server); - - const mockConfig = { - get: (key: string) => config[key], - }; - - server.decorate('server', 'config', jest.fn(() => mockConfig)); - - const mockSavedObjectsClientContract = { - get: jest.fn((type, id) => { - const result = spaces.filter(s => s.id === id); - if (!result.length) { - throw new Error(`not found: [${type}:${id}]`); - } - return result[0]; - }), - find: jest.fn(() => { - return { - total: spaces.length, - saved_objects: spaces, - }; - }), - create: jest.fn((type, attributes, { id }) => { - if (spaces.find(s => s.id === id)) { - throw new Error('conflict'); - } - return {}; - }), - update: jest.fn((type, id) => { - if (!spaces.find(s => s.id === id)) { - throw new Error('not found: during update'); - } - return {}; - }), - delete: jest.fn((type: string, id: string) => { - return {}; - }), - deleteByNamespace: jest.fn(), - }; - - server.savedObjects = { - types: ['visualization', 'dashboard', 'index-pattern', 'globalType'], - schema: new SavedObjectsSchema({ - space: { - isNamespaceAgnostic: true, - hidden: true, - }, - globalType: { - isNamespaceAgnostic: true, - }, - }), - getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract), - importExport: { - getSortedObjectsForExport: jest.fn().mockResolvedValue( - new Readable({ - objectMode: true, - read() { - if (Array.isArray(payload.objects)) { - payload.objects.forEach((o: any) => this.push(o)); - } - this.push(null); - }, - }) - ), - importSavedObjects: jest.fn().mockImplementation(async (opts: Record) => { - const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); - return { - success: true, - successCount: objectsToImport.length, - }; - }), - resolveImportErrors: jest.fn().mockImplementation(async (opts: Record) => { - const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); - return { - success: true, - successCount: objectsToImport.length, - }; - }), - }, - SavedObjectsClient: { - errors: { - isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), - isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')), - }, - }, - }; - - server.plugins.elasticsearch = { - createCluster: jest.fn(), - waitUntilReady: jest.fn(), - getCluster: jest.fn().mockReturnValue({ - callWithRequest: jest.fn(), - callWithInternalUser: jest.fn(), - }), - }; - - const log = { - log: jest.fn(), - trace: jest.fn(), - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - fatal: jest.fn(), - }; - - const coreSetupMock = coreMock.createSetup(); - - const legacyAPI = { - legacyConfig: { - serverBasePath: mockConfig.get('server.basePath'), - serverDefaultRoute: mockConfig.get('server.defaultRoute'), - }, - savedObjects: server.savedObjects, - } as LegacyAPI; - - const service = new SpacesService(log, () => legacyAPI); - const spacesService = await service.setup({ - http: coreSetupMock.http, - elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), - getSpacesAuditLogger: () => ({} as SpacesAuditLogger), - config$: Rx.of({ maxSpaces: 1000 }), - }); - - spacesService.scopedClient = jest.fn((req: any) => { - return Promise.resolve( - new SpacesClient( - null as any, - () => null, - null, - mockSavedObjectsClientContract, - { maxSpaces: 1000 }, - mockSavedObjectsClientContract, - req - ) - ); - }); - - initApiFn({ - getLegacyAPI: () => legacyAPI, - routePreCheckLicenseFn: pre, - savedObjects: server.savedObjects, - spacesService, - log, - legacyRouter: server.route.bind(server), - }); - - teardowns.push(() => server.stop()); - - const headers = { - authorization: 'foo', - }; - - const testRun = async () => { - const response = await server.inject({ - method, - url: path, - headers, - payload, - }); - - if (preCheckLicenseImpl && expectPreCheckLicenseCall) { - expect(pre).toHaveBeenCalled(); - } else { - expect(pre).not.toHaveBeenCalled(); - } - - if (expectSpacesClientCall) { - expect(spacesService.scopedClient).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }) - ); - } else { - expect(spacesService.scopedClient).not.toHaveBeenCalled(); - } - - return response; - }; - - return { - server, - headers, - mockSavedObjectsRepository: mockSavedObjectsClientContract, - mockSavedObjectsService: server.savedObjects, - response: await testRun(), - }; - }; - - return { - request, - teardowns, - }; -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts deleted file mode 100644 index 292fc21a2dd79..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ /dev/null @@ -1,443 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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 { initCopyToSpacesApi } from './copy_to_space'; - -describe('POST /api/spaces/_copy_saved_objects', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initCopyToSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - spaces: ['a-space'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { - const payload = { - spaces: ['a-space'], - objects: [], - }; - - const { mockSavedObjectsService } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - payload, - }); - - expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( - expect.any(Object), - { - excludedWrappers: ['spaces'], - } - ); - }); - - test(`requires space IDs to be unique`, async () => { - const payload = { - spaces: ['a-space', 'a-space'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires well-formed space IDS`, async () => { - const payload = { - spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires objects to be unique`, async () => { - const payload = { - spaces: ['a-space'], - objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_copy_saved_objects', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - spaces: ['a-space'], - objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_copy_saved_objects', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(1); - const [ - importCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; - - expect(importCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - - test('copies to multiple spaces', async () => { - const payload = { - spaces: ['a-space', 'b-space'], - objects: [{ type: 'visualization', id: 'bar' }], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_copy_saved_objects', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.importSavedObjects).toHaveBeenCalledTimes(2); - const [ - firstImportCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[0]; - - expect(firstImportCallOptions).toMatchObject({ - namespace: 'a-space', - }); - - const [ - secondImportCallOptions, - ] = mockSavedObjectsService.importExport.importSavedObjects.mock.calls[1]; - - expect(secondImportCallOptions).toMatchObject({ - namespace: 'b-space', - }); - }); -}); - -describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initCopyToSpacesApi); - - request = setup.request; - teardowns = setup.teardowns; - }); - - afterEach(async () => { - await Promise.all(teardowns.splice(0).map(fn => fn())); - }); - - test(`returns result of routePreCheckLicense`, async () => { - const payload = { - retries: {}, - objects: [], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(responsePayload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [{ type: 'visualization', id: 'bar' }], - }; - - const { mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - expect(mockSavedObjectsService.getScopedSavedObjectsClient).toHaveBeenCalledWith( - expect.any(Object), - { - excludedWrappers: ['spaces'], - } - ); - }); - - test(`requires objects to be unique`, async () => { - const payload = { - retries: {}, - objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test(`requires well-formed space ids`, async () => { - const payload = { - retries: { - ['invalid-space-id!@#$%^&*()']: [ - { - type: 'foo', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [{ type: 'foo', id: 'bar' }], - }; - - const { response } = await request('POST', '/api/spaces/_resolve_copy_saved_objects_errors', { - expectSpacesClientCall: false, - expectPreCheckLicenseCall: false, - payload, - }); - - const { statusCode, payload: responsePayload } = response; - - expect(statusCode).toEqual(400); - expect(JSON.parse(responsePayload)).toMatchInlineSnapshot(` - Object { - "error": "Bad Request", - "message": "Invalid request payload input", - "statusCode": 400, - } - `); - }); - - test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { - const payload = { - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - objects: [ - { - type: 'globalType', - id: 'bar', - }, - { type: 'visualization', id: 'bar' }, - ], - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(1); - const [ - resolveImportErrorsCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; - - expect(resolveImportErrorsCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); - - test('resolves conflicts for multiple spaces', async () => { - const payload = { - objects: [{ type: 'visualization', id: 'bar' }], - retries: { - ['a-space']: [ - { - type: 'visualization', - id: 'bar', - overwrite: true, - }, - ], - ['b-space']: [ - { - type: 'globalType', - id: 'bar', - overwrite: true, - }, - ], - }, - }; - - const { response, mockSavedObjectsService } = await request( - 'POST', - '/api/spaces/_resolve_copy_saved_objects_errors', - { - expectSpacesClientCall: false, - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsService.importExport.resolveImportErrors).toHaveBeenCalledTimes(2); - const [ - resolveImportErrorsFirstCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[0]; - - expect(resolveImportErrorsFirstCallOptions).toMatchObject({ - namespace: 'a-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - - const [ - resolveImportErrorsSecondCallOptions, - ] = mockSavedObjectsService.importExport.resolveImportErrors.mock.calls[1]; - - expect(resolveImportErrorsSecondCallOptions).toMatchObject({ - namespace: 'b-space', - supportedTypes: ['visualization', 'dashboard', 'index-pattern'], - }); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts deleted file mode 100644 index be5a921f91340..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/copy_to_space.ts +++ /dev/null @@ -1,145 +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 Joi from 'joi'; -import { Legacy } from 'kibana'; -import { - copySavedObjectsToSpacesFactory, - resolveCopySavedObjectsToSpacesConflictsFactory, -} from '../../../lib/copy_to_spaces'; -import { ExternalRouteDeps } from '.'; -import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; - -interface CopyPayload { - spaces: string[]; - objects: Array<{ type: string; id: string }>; - includeReferences: boolean; - overwrite: boolean; -} - -interface ResolveConflictsPayload { - objects: Array<{ type: string; id: string }>; - includeReferences: boolean; - retries: { - [spaceId: string]: Array<{ - type: string; - id: string; - overwrite: boolean; - }>; - }; -} - -export function initCopyToSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps; - - legacyRouter({ - method: 'POST', - path: '/api/spaces/_copy_saved_objects', - async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { - const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( - request, - COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS - ); - - const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( - savedObjectsClient, - savedObjects - ); - - const { - spaces: destinationSpaceIds, - objects, - includeReferences, - overwrite, - } = request.payload as CopyPayload; - - const sourceSpaceId = spacesService.getSpaceId(request); - - const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { - objects, - includeReferences, - overwrite, - }); - - return h.response(copyResponse); - }, - options: { - tags: ['access:copySavedObjectsToSpaces'], - validate: { - payload: { - spaces: Joi.array() - .items( - Joi.string().regex(SPACE_ID_REGEX, `lower case, a-z, 0-9, "_", and "-" are allowed`) - ) - .unique(), - objects: Joi.array() - .items(Joi.object({ type: Joi.string(), id: Joi.string() })) - .unique(), - includeReferences: Joi.bool().default(false), - overwrite: Joi.bool().default(false), - }, - }, - pre: [routePreCheckLicenseFn], - }, - }); - - legacyRouter({ - method: 'POST', - path: '/api/spaces/_resolve_copy_saved_objects_errors', - async handler(request: Legacy.Request, h: Legacy.ResponseToolkit) { - const savedObjectsClient = savedObjects.getScopedSavedObjectsClient( - request, - COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS - ); - - const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( - savedObjectsClient, - savedObjects - ); - - const { objects, includeReferences, retries } = request.payload as ResolveConflictsPayload; - - const sourceSpaceId = spacesService.getSpaceId(request); - - const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( - sourceSpaceId, - { - objects, - includeReferences, - retries, - } - ); - - return h.response(resolveConflictsResponse); - }, - options: { - tags: ['access:copySavedObjectsToSpaces'], - validate: { - payload: Joi.object({ - objects: Joi.array() - .items(Joi.object({ type: Joi.string(), id: Joi.string() })) - .required() - .unique(), - includeReferences: Joi.bool().default(false), - retries: Joi.object() - .pattern( - SPACE_ID_REGEX, - Joi.array().items( - Joi.object({ - type: Joi.string().required(), - id: Joi.string().required(), - overwrite: Joi.boolean().default(false), - }) - ) - ) - .required(), - }).default(), - }, - pre: [routePreCheckLicenseFn], - }, - }); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts deleted file mode 100644 index a1a23604f159a..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.test.ts +++ /dev/null @@ -1,85 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - const { statusCode, payload } = response; - - expect(statusCode).toEqual(403); - expect(JSON.parse(payload)).toMatchObject({ - message: 'test forbidden message', - }); - }); - - test('DELETE spaces/{id} throws when deleting a non-existent space', async () => { - const { response } = await request('DELETE', '/api/spaces/space/not-a-space'); - - const { statusCode } = response; - - expect(statusCode).toEqual(404); - }); - - 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/legacy/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts deleted file mode 100644 index 5357c38e0e9ae..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.test.ts +++ /dev/null @@ -1,109 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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(`'GET spaces' returns all available spaces with the 'any' purpose`, async () => { - const { response } = await request('GET', '/api/spaces/space?purpose=any'); - - 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(`'GET spaces' returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => { - const { response } = await request( - 'GET', - '/api/spaces/space?purpose=copySavedObjectsIntoSpace' - ); - - 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: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - 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/legacy/plugins/spaces/server/routes/api/external/index.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts deleted file mode 100644 index 72ddc193e5c9f..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/index.ts +++ /dev/null @@ -1,47 +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 { Legacy } from 'kibana'; -import { Logger, SavedObjectsService } from 'src/core/server'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { initDeleteSpacesApi } from './delete'; -import { initGetSpacesApi } from './get'; -import { initPostSpacesApi } from './post'; -import { initPutSpacesApi } from './put'; -import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service'; -import { initCopyToSpacesApi } from './copy_to_space'; - -type Omit = Pick>; - -interface RouteDeps { - xpackMain: XPackMainPlugin; - legacyRouter: Legacy.Server['route']; - savedObjects: SavedObjectsService; - spacesService: SpacesServiceSetup; - log: Logger; -} - -export interface ExternalRouteDeps extends Omit { - routePreCheckLicenseFn: any; -} - -export type ExternalRouteRequestFacade = Legacy.Request; - -export function initExternalSpacesApi({ xpackMain, ...rest }: RouteDeps) { - const routePreCheckLicenseFn = routePreCheckLicense({ xpackMain }); - - const deps: ExternalRouteDeps = { - ...rest, - routePreCheckLicenseFn, - }; - - initDeleteSpacesApi(deps); - initGetSpacesApi(deps); - initPostSpacesApi(deps); - initPutSpacesApi(deps); - initCopyToSpacesApi(deps); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts deleted file mode 100644 index c53aaf29636a4..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.test.ts +++ /dev/null @@ -1,128 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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', - disabledFeatures: ['foo'], - }; - - const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', { - payload, - }); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith( - 'space', - { name: 'my new space', description: 'with a description', disabledFeatures: ['foo'] }, - { id: 'my-space-id' } - ); - }); - - 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: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - 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.', - statusCode: 409, - }); - }); - - test('POST /space should not require disabledFeatures to be specified', async () => { - const payload = { - id: 'my-space-id', - name: 'my new space', - description: 'with a description', - }; - - const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', { - payload, - }); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith( - 'space', - { name: 'my new space', description: 'with a description', disabledFeatures: [] }, - { id: 'my-space-id' } - ); - }); -}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts deleted file mode 100644 index d2ac1f89e1df9..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.test.ts +++ /dev/null @@ -1,156 +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. - */ -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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', - disabledFeatures: [], - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: 'with a description', - disabledFeatures: [], - }); - }); - - test('PUT /space should allow an empty description', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: '', - disabledFeatures: ['foo'], - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: '', - disabledFeatures: ['foo'], - }); - }); - - test('PUT /space should not require disabledFeatures', async () => { - const payload = { - id: 'a-space', - name: 'my updated space', - description: '', - }; - - const { mockSavedObjectsRepository, response } = await request( - 'PUT', - '/api/spaces/space/a-space', - { - payload, - } - ); - - const { statusCode } = response; - - expect(statusCode).toEqual(200); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); - expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { - name: 'my updated space', - description: '', - disabledFeatures: [], - }); - }); - - 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: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - 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/legacy/plugins/spaces/server/routes/api/v1/index.ts b/x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts deleted file mode 100644 index ddbca3e8e3d71..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/index.ts +++ /dev/null @@ -1,36 +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 { Legacy } from 'kibana'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; -import { initInternalSpacesApi } from './spaces'; -import { SpacesServiceSetup } from '../../../new_platform/spaces_service/spaces_service'; -import { LegacyAPI } from '../../../new_platform/plugin'; - -type Omit = Pick>; - -interface RouteDeps { - xpackMain: XPackMainPlugin; - spacesService: SpacesServiceSetup; - getLegacyAPI(): LegacyAPI; - legacyRouter: Legacy.Server['route']; -} - -export interface InternalRouteDeps extends Omit { - routePreCheckLicenseFn: any; -} - -export function initInternalApis({ xpackMain, ...rest }: RouteDeps) { - const routePreCheckLicenseFn = routePreCheckLicense({ xpackMain }); - - const deps: InternalRouteDeps = { - ...rest, - routePreCheckLicenseFn, - }; - - initInternalSpacesApi(deps); -} diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts b/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts deleted file mode 100644 index 4d9952f4ab3dc..0000000000000 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.test.ts +++ /dev/null @@ -1,93 +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. - */ - -jest.mock('../../../lib/route_pre_check_license', () => { - return { - routePreCheckLicense: () => (request: any, h: any) => h.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 { initInternalSpacesApi } from './spaces'; - -describe('Spaces API', () => { - let request: RequestRunner; - let teardowns: TeardownFn[]; - - beforeEach(() => { - const setup = createTestHandler(initInternalSpacesApi); - - 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: () => Boom.forbidden('test forbidden message'), - expectSpacesClientCall: false, - }); - - 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/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts new file mode 100644 index 0000000000000..50423517bc918 --- /dev/null +++ b/x-pack/plugins/spaces/common/constants.ts @@ -0,0 +1,23 @@ +/* + * 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 const DEFAULT_SPACE_ID = `default`; + +/** + * The minimum number of spaces required to show a search control. + */ +export const SPACE_SEARCH_COUNT_THRESHOLD = 8; + +/** + * The maximum number of characters allowed in the Space Avatar's initials + */ +export const MAX_SPACE_INITIALS = 2; + +/** + * The type name used within the Monitoring index to publish spaces stats. + * @type {string} + */ +export const KIBANA_SPACES_STATS_TYPE = 'spaces'; diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/config.ts b/x-pack/plugins/spaces/common/index.ts similarity index 51% rename from x-pack/legacy/plugins/spaces/server/new_platform/config.ts rename to x-pack/plugins/spaces/common/index.ts index fbe8edb14f19b..8961c9c5ccf79 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/config.ts +++ b/x-pack/plugins/spaces/common/index.ts @@ -4,12 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; - -export const config = { - schema: schema.object({ - maxSpaces: schema.number({ defaultValue: 1000 }), - }), -}; - -export type SpacesConfigType = TypeOf; +export { isReservedSpace } from './is_reserved_space'; +export { MAX_SPACE_INITIALS } from './constants'; diff --git a/x-pack/plugins/spaces/common/is_reserved_space.test.ts b/x-pack/plugins/spaces/common/is_reserved_space.test.ts new file mode 100644 index 0000000000000..dd1372183ed8a --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { isReservedSpace } from './is_reserved_space'; +import { Space } from './model/space'; + +test('it returns true for reserved spaces', () => { + const space: Space = { + id: '', + name: '', + disabledFeatures: [], + _reserved: true, + }; + + expect(isReservedSpace(space)).toEqual(true); +}); + +test('it returns false for non-reserved spaces', () => { + const space: Space = { + id: '', + name: '', + disabledFeatures: [], + }; + + expect(isReservedSpace(space)).toEqual(false); +}); + +test('it handles empty input', () => { + // @ts-ignore + expect(isReservedSpace()).toEqual(false); +}); diff --git a/x-pack/plugins/spaces/common/is_reserved_space.ts b/x-pack/plugins/spaces/common/is_reserved_space.ts new file mode 100644 index 0000000000000..788ef80c194ce --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.ts @@ -0,0 +1,18 @@ +/* + * 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 { get } from 'lodash'; +import { Space } from './model/space'; + +/** + * Returns whether the given Space is reserved or not. + * + * @param space the space + * @returns boolean + */ +export function isReservedSpace(space?: Partial | null): boolean { + return get(space, '_reserved', false); +} diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/index.ts b/x-pack/plugins/spaces/common/model/space.ts similarity index 54% rename from x-pack/legacy/plugins/spaces/server/new_platform/index.ts rename to x-pack/plugins/spaces/common/model/space.ts index edf27e2dd819b..c44ce41ec51c0 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/index.ts +++ b/x-pack/plugins/spaces/common/model/space.ts @@ -4,9 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializerContext } from 'src/core/server'; -import { Plugin } from './plugin'; - -export function plugin(initializerContext: PluginInitializerContext) { - return new Plugin(initializerContext); +export interface Space { + id: string; + name: string; + description?: string; + color?: string; + initials?: string; + disabledFeatures: string[]; + _reserved?: boolean; + imageUrl?: string; } diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts new file mode 100644 index 0000000000000..58c36da33dbd7 --- /dev/null +++ b/x-pack/plugins/spaces/common/model/types.ts @@ -0,0 +1,7 @@ +/* + * 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 type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace'; diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json new file mode 100644 index 0000000000000..15d900bf99e14 --- /dev/null +++ b/x-pack/plugins/spaces/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "spaces", + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "spaces"], + "requiredPlugins": ["features", "licensing"], + "server": true, + "ui": false +} diff --git a/x-pack/plugins/spaces/server/config.ts b/x-pack/plugins/spaces/server/config.ts new file mode 100644 index 0000000000000..476cd94630a2b --- /dev/null +++ b/x-pack/plugins/spaces/server/config.ts @@ -0,0 +1,21 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; +import { PluginInitializerContext } from 'src/core/server'; +import { Observable } from 'rxjs'; + +export const ConfigSchema = schema.object({ + maxSpaces: schema.number({ defaultValue: 1000 }), +}); + +export function createConfig$(context: PluginInitializerContext) { + return context.config.create>(); +} + +export type ConfigType = ReturnType extends Observable + ? P + : ReturnType; diff --git a/x-pack/plugins/spaces/server/index.ts b/x-pack/plugins/spaces/server/index.ts new file mode 100644 index 0000000000000..21d6c840fb017 --- /dev/null +++ b/x-pack/plugins/spaces/server/index.ts @@ -0,0 +1,22 @@ +/* + * 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 { PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigSchema } from './config'; +import { Plugin } from './plugin'; + +// These exports are part of public Spaces plugin contract, any change in signature of exported +// functions or removal of exports should be considered as a breaking change. Ideally we should +// reduce number of such exports to zero and provide everything we want to expose via Setup/Start +// run-time contracts. + +// end public contract exports + +export { SpacesPluginSetup } from './plugin'; + +export const config = { schema: ConfigSchema }; +export const plugin = (initializerContext: PluginInitializerContext) => + new Plugin(initializerContext); diff --git a/x-pack/legacy/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap rename to x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap diff --git a/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap new file mode 100644 index 0000000000000..d08be39f9282e --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addSpaceIdToPath it throws an error when the requested path does not start with a slash 1`] = `"path must start with a /"`; diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.test.ts b/x-pack/plugins/spaces/server/lib/audit_logger.test.ts new file mode 100644 index 0000000000000..94e9a6a35be64 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/audit_logger.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { SpacesAuditLogger } from './audit_logger'; + +const createMockAuditLogger = () => { + return { + log: jest.fn(), + }; +}; + +describe(`#savedObjectsAuthorizationFailure`, () => { + test('logs auth failure with spaceIds via auditLogger', () => { + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const spaceIds = ['foo-space-1', 'foo-space-2']; + + securityAuditLogger.spacesAuthorizationFailure(username, action, spaceIds); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_failure', + expect.stringContaining(`${username} unauthorized to ${action} ${spaceIds.join(',')} spaces`), + { + username, + action, + spaceIds, + } + ); + }); + + test('logs auth failure without spaceIds via auditLogger', () => { + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + + securityAuditLogger.spacesAuthorizationFailure(username, action); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_failure', + expect.stringContaining(`${username} unauthorized to ${action} spaces`), + { + username, + action, + } + ); + }); +}); + +describe(`#savedObjectsAuthorizationSuccess`, () => { + test('logs auth success with spaceIds via auditLogger', () => { + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const spaceIds = ['foo-space-1', 'foo-space-2']; + + securityAuditLogger.spacesAuthorizationSuccess(username, action, spaceIds); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_success', + expect.stringContaining(`${username} authorized to ${action} ${spaceIds.join(',')} spaces`), + { + username, + action, + spaceIds, + } + ); + }); + + test('logs auth success without spaceIds via auditLogger', () => { + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + + securityAuditLogger.spacesAuthorizationSuccess(username, action); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_success', + expect.stringContaining(`${username} authorized to ${action} spaces`), + { + username, + action, + } + ); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.ts b/x-pack/plugins/spaces/server/lib/audit_logger.ts new file mode 100644 index 0000000000000..f770fa29bd396 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/audit_logger.ts @@ -0,0 +1,36 @@ +/* + * 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 class SpacesAuditLogger { + private readonly auditLogger: any; + + constructor(auditLogger: any) { + this.auditLogger = auditLogger; + } + public spacesAuthorizationFailure(username: string, action: string, spaceIds?: string[]) { + this.auditLogger.log( + 'spaces_authorization_failure', + `${username} unauthorized to ${action}${spaceIds ? ' ' + spaceIds.join(',') : ''} spaces`, + { + username, + action, + spaceIds, + } + ); + } + + public spacesAuthorizationSuccess(username: string, action: string, spaceIds?: string[]) { + this.auditLogger.log( + 'spaces_authorization_success', + `${username} authorized to ${action}${spaceIds ? ' ' + spaceIds.join(',') : ''} spaces`, + { + username, + action, + spaceIds, + } + ); + } +} diff --git a/x-pack/plugins/spaces/server/lib/check_license.ts b/x-pack/plugins/spaces/server/lib/check_license.ts new file mode 100644 index 0000000000000..15dea834d2f15 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/check_license.ts @@ -0,0 +1,41 @@ +/* + * 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 interface LicenseCheckResult { + showSpaces: boolean; +} + +/** + * Returns object that defines behavior of the spaces related features based + * on the license information extracted from the xPackInfo. + * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. + * @returns {LicenseCheckResult} + */ +export function checkLicense(xPackInfo: any): LicenseCheckResult { + if (!xPackInfo.isAvailable()) { + return { + showSpaces: false, + }; + } + + const isAnyXpackLicense = xPackInfo.license.isOneOf([ + 'basic', + 'standard', + 'gold', + 'platinum', + 'trial', + ]); + + if (!isAnyXpackLicense) { + return { + showSpaces: false, + }; + } + + return { + showSpaces: true, + }; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/copy_to_spaces.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/index.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/create_empty_failure_response.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/get_eligible_types.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/read_stream_to_completion.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/lib/readable_stream_from_array.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/resolve_copy_conflicts.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts b/x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/copy_to_spaces/types.ts rename to x-pack/plugins/spaces/server/lib/copy_to_spaces/types.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts rename to x-pack/plugins/spaces/server/lib/create_default_space.test.ts index 0476bf9ba929b..556035a778a21 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.test.ts +++ b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts @@ -3,23 +3,10 @@ * 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('../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn(), -})); -import * as Rx from 'rxjs'; import Boom from 'boom'; -import { getClient } from '../../../../server/lib/get_client_shield'; import { createDefaultSpace } from './create_default_space'; -import { SavedObjectsService } from 'src/core/server'; -import { ElasticsearchServiceSetup } from 'src/core/server'; - -let mockCallWithRequest; -beforeEach(() => { - mockCallWithRequest = jest.fn(); - (getClient as jest.Mock).mockReturnValue({ - callWithRequest: mockCallWithRequest, - }); -}); +import { SavedObjectsService, ClusterClient } from 'src/core/server'; + interface MockServerSettings { defaultExists?: boolean; simulateGetErrorCondition?: boolean; @@ -84,11 +71,9 @@ const createMockDeps = (settings: MockServerSettings = {}) => { return { config: mockServer.config(), savedObjects: (mockServer.savedObjects as unknown) as SavedObjectsService, - elasticsearch: ({ - dataClient$: Rx.of({ - callAsInternalUser: jest.fn(), - }), - } as unknown) as ElasticsearchServiceSetup, + esClient: ({ + callAsInternalUser: jest.fn(), + } as unknown) as ClusterClient, }; }; diff --git a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts b/x-pack/plugins/spaces/server/lib/create_default_space.ts similarity index 81% rename from x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts rename to x-pack/plugins/spaces/server/lib/create_default_space.ts index bde9a5132182b..abb76c73362d5 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/create_default_space.ts +++ b/x-pack/plugins/spaces/server/lib/create_default_space.ts @@ -6,21 +6,18 @@ import { i18n } from '@kbn/i18n'; -import { first } from 'rxjs/operators'; -import { SavedObjectsService, CoreSetup } from 'src/core/server'; +import { SavedObjectsService, ClusterClient } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../common/constants'; interface Deps { - elasticsearch: CoreSetup['elasticsearch']; + esClient: ClusterClient; savedObjects: SavedObjectsService; } -export async function createDefaultSpace({ elasticsearch, savedObjects }: Deps) { +export async function createDefaultSpace({ esClient, savedObjects }: Deps) { const { getSavedObjectsRepository, SavedObjectsClient } = savedObjects; - const client = await elasticsearch.dataClient$.pipe(first()).toPromise(); - - const savedObjectsRepository = getSavedObjectsRepository(client.callAsInternalUser, ['space']); + const savedObjectsRepository = getSavedObjectsRepository(esClient.callAsInternalUser, ['space']); const defaultSpaceExists = await doesDefaultSpaceExist( SavedObjectsClient, diff --git a/x-pack/plugins/spaces/server/lib/errors.ts b/x-pack/plugins/spaces/server/lib/errors.ts new file mode 100644 index 0000000000000..cf331bbb3c448 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/errors.ts @@ -0,0 +1,17 @@ +/* + * 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 { boomify, isBoom } from 'boom'; +import { ResponseError, CustomHttpResponseOptions } from 'src/core/server'; + +export function wrapError(error: any): CustomHttpResponseOptions { + const boom = isBoom(error) ? error : boomify(error); + return { + body: boom.output.payload, + headers: boom.output.headers, + statusCode: boom.output.statusCode, + }; +} diff --git a/x-pack/plugins/spaces/server/lib/get_active_space.ts b/x-pack/plugins/spaces/server/lib/get_active_space.ts new file mode 100644 index 0000000000000..907b7b164b69b --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_active_space.ts @@ -0,0 +1,24 @@ +/* + * 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 { wrapError } from './errors'; +import { SpacesClient } from './spaces_client'; +import { getSpaceIdFromPath } from './spaces_url_parser'; + +export async function getActiveSpace( + spacesClient: SpacesClient, + requestBasePath: string, + serverBasePath: string +): Promise { + const spaceId = getSpaceIdFromPath(requestBasePath, serverBasePath); + + try { + return spacesClient.get(spaceId); + } catch (e) { + throw wrapError(e); + } +} diff --git a/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts new file mode 100644 index 0000000000000..b27d119a0d310 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.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 { getSpaceSelectorUrl } from './get_space_selector_url'; + +describe('getSpaceSelectorUrl', () => { + it('returns / when no server base path is defined', () => { + expect(getSpaceSelectorUrl('')).toEqual('/spaces/space_selector'); + }); + + it('returns the server base path when defined', () => { + expect(getSpaceSelectorUrl('/my/server/base/path')).toEqual( + '/my/server/base/path/spaces/space_selector' + ); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts new file mode 100644 index 0000000000000..6d088fda757de --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts @@ -0,0 +1,9 @@ +/* + * 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 getSpaceSelectorUrl(serverBasePath: string = '') { + return `${serverBasePath}/spaces/space_selector`; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts similarity index 62% rename from x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts rename to x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts index 6609ca42a7f67..912cccbc01782 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts @@ -5,12 +5,24 @@ */ import { getSpacesUsageCollector, UsageStats } from './get_spaces_usage_collector'; +import * as Rx from 'rxjs'; +import { PluginsSetup } from '../plugin'; +import { Feature } from '../../../features/server'; +import { ILicense, LicensingPluginSetup } from '../../../licensing/server'; + +interface SetupOpts { + license?: Partial; + features?: Feature[]; +} -function getServerMock(customization?: any) { +function setup({ + license = { isAvailable: true }, + features = [{ id: 'feature1' } as Feature, { id: 'feature2' } as Feature], +}: SetupOpts = {}) { class MockUsageCollector { private fetch: any; - constructor(server: any, { fetch }: any) { + constructor({ fetch }: any) { this.fetch = fetch; } // to make typescript happy @@ -19,39 +31,23 @@ function getServerMock(customization?: any) { } } - const getLicenseCheckResults = jest.fn().mockReturnValue({}); - const defaultServerMock = { - plugins: { - xpack_main: { - info: { - isAvailable: jest.fn().mockReturnValue(true), - feature: () => ({ - getLicenseCheckResults, - }), - license: { - isOneOf: jest.fn().mockReturnValue(false), - getType: jest.fn().mockReturnValue('platinum'), - }, - toJSON: () => ({ b: 1 }), - }, - getFeatures: jest.fn().mockReturnValue([{ id: 'feature1' }, { id: 'feature2' }]), - }, - }, - expose: () => { - return; - }, - log: () => { - return; - }, + const licensing = { + license$: Rx.of(license), + } as LicensingPluginSetup; + + const featuresSetup = ({ + getFeatures: jest.fn().mockReturnValue(features), + } as unknown) as PluginsSetup['features']; + + return { + licensing, + features: featuresSetup, usage: { collectorSet: { - makeUsageCollector: (options: any) => { - return new MockUsageCollector(defaultServerMock, options); - }, + makeUsageCollector: (options: any) => new MockUsageCollector(options), }, }, }; - return Object.assign(defaultServerMock, customization); } const defaultCallClusterMock = jest.fn().mockResolvedValue({ @@ -73,17 +69,14 @@ const defaultCallClusterMock = jest.fn().mockResolvedValue({ }); describe('with a basic license', () => { - let serverWithBasicLicenseMock: any; let usageStats: UsageStats; beforeAll(async () => { - serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = jest - .fn() - .mockReturnValue('basic'); + const { features, licensing, usage } = setup({ license: { isAvailable: true, type: 'basic' } }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithBasicLicenseMock.usage, - xpackMain: serverWithBasicLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); @@ -113,13 +106,12 @@ describe('with a basic license', () => { describe('with no license', () => { let usageStats: UsageStats; beforeAll(async () => { - const serverWithNoLicenseMock = getServerMock(); - serverWithNoLicenseMock.plugins.xpack_main.info.isAvailable = jest.fn().mockReturnValue(false); - + const { features, licensing, usage } = setup({ license: { isAvailable: false } }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithNoLicenseMock.usage, - xpackMain: serverWithNoLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); @@ -142,17 +134,16 @@ describe('with no license', () => { }); describe('with platinum license', () => { - let serverWithPlatinumLicenseMock: any; let usageStats: UsageStats; beforeAll(async () => { - serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = jest - .fn() - .mockReturnValue('platinum'); + const { features, licensing, usage } = setup({ + license: { isAvailable: true, type: 'platinum' }, + }); const { fetch: getSpacesUsage } = getSpacesUsageCollector({ kibanaIndex: '.kibana', - usage: serverWithPlatinumLicenseMock.usage, - xpackMain: serverWithPlatinumLicenseMock.plugins.xpack_main, + usage, + features, + licensing, }); usageStats = await getSpacesUsage(defaultCallClusterMock); }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts similarity index 85% rename from x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts rename to x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts index 623f613faaa0c..afdf7ea77c2ed 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/get_spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts @@ -6,10 +6,11 @@ import { get } from 'lodash'; import { CallAPIOptions } from 'src/core/server'; -import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; +import { take } from 'rxjs/operators'; // @ts-ignore -import { KIBANA_STATS_TYPE_MONITORING } from '../../../monitoring/common/constants'; +import { KIBANA_STATS_TYPE_MONITORING } from '../../../../legacy/plugins/monitoring/common/constants'; import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants'; +import { PluginsSetup } from '../plugin'; type CallCluster = ( endpoint: string, @@ -38,14 +39,14 @@ interface SpacesAggregationResponse { async function getSpacesUsage( callCluster: CallCluster, kibanaIndex: string, - xpackMainPlugin: XPackMainPlugin, + features: PluginsSetup['features'], spacesAvailable: boolean ) { if (!spacesAvailable) { return {} as UsageStats; } - const knownFeatureIds = xpackMainPlugin.getFeatures().map(feature => feature.id); + const knownFeatureIds = features.getFeatures().map(feature => feature.id); const resp = await callCluster('search', { index: kibanaIndex, @@ -115,7 +116,8 @@ export interface UsageStats { interface CollectorDeps { kibanaIndex: string; usage: { collectorSet: any }; - xpackMain: XPackMainPlugin; + features: PluginsSetup['features']; + licensing: PluginsSetup['licensing']; } /* @@ -128,13 +130,13 @@ export function getSpacesUsageCollector(deps: CollectorDeps) { type: KIBANA_SPACES_STATS_TYPE, isReady: () => true, fetch: async (callCluster: CallCluster) => { - const xpackInfo = deps.xpackMain.info; - const available = xpackInfo && xpackInfo.isAvailable(); // some form of spaces is available for all valid licenses + const license = await deps.licensing.license$.pipe(take(1)).toPromise(); + const available = license.isAvailable; // some form of spaces is available for all valid licenses const usageStats = await getSpacesUsage( callCluster, deps.kibanaIndex, - deps.xpackMain, + deps.features, available ); diff --git a/x-pack/plugins/spaces/server/lib/migrations/index.ts b/x-pack/plugins/spaces/server/lib/migrations/index.ts new file mode 100644 index 0000000000000..b303a8489ffb0 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { migrateToKibana660 } from './migrate_6x'; diff --git a/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.ts b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.ts new file mode 100644 index 0000000000000..964eb8137685f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { migrateToKibana660 } from './migrate_6x'; + +describe('migrateTo660', () => { + it('adds a "disabledFeatures" attribute initialized as an empty array', () => { + expect( + migrateToKibana660({ + id: 'space:foo', + attributes: {}, + }) + ).toEqual({ + id: 'space:foo', + attributes: { + disabledFeatures: [], + }, + }); + }); + + it('does not initialize "disabledFeatures" if the property already exists', () => { + // This scenario shouldn't happen organically. Protecting against defects in the migration. + expect( + migrateToKibana660({ + id: 'space:foo', + attributes: { + disabledFeatures: ['foo', 'bar', 'baz'], + }, + }) + ).toEqual({ + id: 'space:foo', + attributes: { + disabledFeatures: ['foo', 'bar', 'baz'], + }, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts new file mode 100644 index 0000000000000..0c080a8dabb0a --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/migrations/migrate_6x.ts @@ -0,0 +1,12 @@ +/* + * 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 migrateToKibana660(doc: Record) { + if (!doc.attributes.hasOwnProperty('disabledFeatures')) { + doc.attributes.disabledFeatures = []; + } + return doc; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/index.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/index.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts similarity index 95% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts index 677b10de8ecbf..2809b08a0d88e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.test.ts @@ -12,21 +12,20 @@ import { HttpServiceSetup, CoreSetup, SavedObjectsService, -} from '../../../../../../../src/core/server'; +} from '../../../../../../src/core/server'; import { elasticsearchServiceMock, loggingServiceMock, -} from '../../../../../../../src/core/server/mocks'; -import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; -import { LegacyAPI } from '../../new_platform/plugin'; -import { SpacesService } from '../../new_platform/spaces_service'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; +} from '../../../../../../src/core/server/mocks'; +import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import { LegacyAPI, PluginsSetup } from '../../plugin'; +import { SpacesService } from '../../spaces_service'; import { SpacesAuditLogger } from '../audit_logger'; -import { SecurityPlugin } from '../../../../security'; import { convertSavedObjectToSpace } from '../../routes/lib'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { Feature } from '../../../../../../plugins/features/server'; import { initSpacesOnPostAuthRequestInterceptor } from './on_post_auth_interceptor'; +import { Feature } from '../../../../features/server'; +import { OptionalPlugin } from '../../../../../legacy/server/lib/optional_plugin'; +import { SecurityPlugin } from '../../../../../legacy/plugins/security'; describe('onPostAuthInterceptor', () => { let root: ReturnType; @@ -115,7 +114,7 @@ describe('onPostAuthInterceptor', () => { .asLoggerFactory() .get('xpack', 'spaces'); - const xpackMainPlugin = { + const featuresPlugin = { getFeatures: () => [ { @@ -139,7 +138,7 @@ describe('onPostAuthInterceptor', () => { app: ['kibana'], }, ] as Feature[], - } as XPackMainPlugin; + } as PluginsSetup['features']; const savedObjectsService = { SavedObjectsClient: { @@ -176,7 +175,7 @@ describe('onPostAuthInterceptor', () => { const spacesService = await service.setup({ http: (http as unknown) as CoreSetup['http'], elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: {} as OptionalPlugin, + getSecurity: () => ({} as OptionalPlugin), getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of({ maxSpaces: 1000 }), }); @@ -209,7 +208,7 @@ describe('onPostAuthInterceptor', () => { getLegacyAPI: () => legacyAPI, http: (http as unknown) as CoreSetup['http'], log: loggingMock, - xpackMain: xpackMainPlugin, + features: featuresPlugin, spacesService, }); @@ -333,7 +332,7 @@ describe('onPostAuthInterceptor', () => { expect(response.body).toMatchInlineSnapshot(` Object { "error": "Internal Server Error", - "message": "unknown error retrieving all spaces", + "message": "An internal server error occurred", "statusCode": 500, } `); diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts similarity index 88% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts index 0fed454621e04..f393edce9f692 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_post_auth_interceptor.ts @@ -7,30 +7,29 @@ import { Logger, CoreSetup } from 'src/core/server'; import { Space } from '../../../common/model/space'; import { wrapError } from '../errors'; import { addSpaceIdToPath } from '../spaces_url_parser'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; -import { LegacyAPI } from '../../new_platform/plugin'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; +import { LegacyAPI, PluginsSetup } from '../../plugin'; import { getSpaceSelectorUrl } from '../get_space_selector_url'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; export interface OnPostAuthInterceptorDeps { getLegacyAPI(): LegacyAPI; http: CoreSetup['http']; - xpackMain: XPackMainPlugin; + features: PluginsSetup['features']; spacesService: SpacesServiceSetup; log: Logger; } export function initSpacesOnPostAuthRequestInterceptor({ - xpackMain, + features, getLegacyAPI, spacesService, log, http, }: OnPostAuthInterceptorDeps) { - const { serverBasePath, serverDefaultRoute } = getLegacyAPI().legacyConfig; - http.registerOnPostAuth(async (request, response, toolkit) => { + const { serverBasePath, serverDefaultRoute } = getLegacyAPI().legacyConfig; + const path = request.url.pathname!; const spaceId = spacesService.getSpaceId(request); @@ -65,11 +64,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ }); } } catch (error) { - const wrappedError = wrapError(error); - return response.customError({ - body: wrappedError, - statusCode: wrappedError.output.statusCode, - }); + return response.customError(wrapError(error)); } } @@ -98,7 +93,7 @@ export function initSpacesOnPostAuthRequestInterceptor({ if (appId !== 'kibana' && space && space.disabledFeatures.length > 0) { log.debug(`Verifying application is available: "${appId}"`); - const allFeatures = xpackMain.getFeatures(); + const allFeatures = features.getFeatures(); const isRegisteredApp = allFeatures.some(feature => feature.app.includes(appId)); if (isRegisteredApp) { diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts similarity index 97% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts index e8bfab9fb1df0..f2305f9dbe16e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.test.ts @@ -12,10 +12,10 @@ import { KibanaRequest, KibanaResponseFactory, CoreSetup, -} from '../../../../../../../src/core/server'; +} from '../../../../../../src/core/server'; -import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; -import { LegacyAPI } from '../../new_platform/plugin'; +import * as kbnTestServer from '../../../../../../src/test_utils/kbn_server'; +import { LegacyAPI } from '../../plugin'; describe('onRequestInterceptor', () => { let root: ReturnType; diff --git a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts similarity index 96% rename from x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts rename to x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index 5da9bdbe6543f..78f26f56f5fee 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -13,7 +13,7 @@ import { format } from 'url'; import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { getSpaceIdFromPath } from '../spaces_url_parser'; import { modifyUrl } from '../utils/url'; -import { LegacyAPI } from '../../new_platform/plugin'; +import { LegacyAPI } from '../../plugin'; export interface OnRequestInterceptorDeps { getLegacyAPI(): LegacyAPI; diff --git a/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts new file mode 100644 index 0000000000000..62eeb5c6bf992 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts @@ -0,0 +1,24 @@ +/* + * 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 { XPackMainPlugin } from '../../../../legacy/plugins/xpack_main/xpack_main'; + +interface LicenseCheckDeps { + xpackMain: XPackMainPlugin; +} + +export function routePreCheckLicense({ xpackMain }: LicenseCheckDeps) { + const pluginId = 'spaces'; + return function forbidApiAccess(request: any) { + const licenseCheckResults = xpackMain.info.feature(pluginId).getLicenseCheckResults(); + if (!licenseCheckResults.showSpaces) { + return Boom.forbidden(licenseCheckResults.linksMessage); + } else { + return ''; + } + }; +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap rename to x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts similarity index 88% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts index 466c3237fd7db..aa61af07c268e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts @@ -6,7 +6,7 @@ import { SavedObjectsClientWrapperFactory } from 'src/core/server'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; export function spacesSavedObjectsClientWrapperFactory( spacesService: SpacesServiceSetup, diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts similarity index 99% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts index 4a5796b2e4ea2..7e1c4ff211a6f 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_SPACE_ID } from '../../../common/constants'; import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; -import { spacesServiceMock } from '../../new_platform/spaces_service/spaces_service.mock'; +import { spacesServiceMock } from '../../spaces_service/spaces_service.mock'; const types = ['foo', 'bar', 'space']; diff --git a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts similarity index 98% rename from x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts rename to x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts index d47a22e8d4545..2c5e4d0998b51 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -14,7 +14,7 @@ import { SavedObjectsFindOptions, SavedObjectsUpdateOptions, } from 'src/core/server'; -import { SpacesServiceSetup } from '../../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../utils/namespace'; interface SpacesSavedObjectsClientOptions { diff --git a/x-pack/plugins/spaces/server/lib/space_schema.test.ts b/x-pack/plugins/spaces/server/lib/space_schema.test.ts new file mode 100644 index 0000000000000..7e2d422ae202a --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_schema.test.ts @@ -0,0 +1,181 @@ +/* + * 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 { spaceSchema } from './space_schema'; + +const defaultProperties = { + id: 'foo', + name: 'foo', +}; + +describe('#id', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: undefined, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: expected value of type [any] but got [undefined]"` + ); + }); + + test('allows lowercase a-z, 0-9, "_" and "-"', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: 'abcdefghijklmnopqrstuvwxyz0123456789_-', + }) + ).not.toThrowError(); + }); + + test(`doesn't allow uppercase`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: 'Foo', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"` + ); + }); + + test(`doesn't allow an empty string`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: '', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[id]: must be lower case, a-z, 0-9, '_', and '-' are allowed"` + ); + }); + + ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', ',', '.', '/', '?'].forEach( + invalidCharacter => { + test(`doesn't allow ${invalidCharacter}`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + id: `foo-${invalidCharacter}`, + }) + ).toThrowError(); + }); + } + ); +}); + +describe('#color', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: undefined, + }) + ).not.toThrowError(); + }); + + test(`doesn't allow an empty string`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`allows lower case hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#aabbcc', + }) + ).not.toThrowError(); + }); + + test(`allows upper case hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#AABBCC', + }) + ).not.toThrowError(); + }); + + test(`allows numeric hex color code`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '#123456', + }) + ).not.toThrowError(); + }); + + test(`must start with a hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '123456', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`cannot exceed 6 digits following the hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '1234567', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); + + test(`cannot be fewer than 6 digits following the hash`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + color: '12345', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[color]: must be a 6 digit hex color, starting with a #"` + ); + }); +}); + +describe('#imageUrl', () => { + test('is optional', () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: undefined, + }) + ).not.toThrowError(); + }); + + test(`must start with data:image`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: 'notValid', + }) + ).toThrowErrorMatchingInlineSnapshot(`"[imageUrl]: must start with 'data:image'"`); + }); + + test(`checking that a valid image is accepted as imageUrl`, () => { + expect(() => + spaceSchema.validate({ + ...defaultProperties, + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==', + }) + ).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/space_schema.ts b/x-pack/plugins/spaces/server/lib/space_schema.ts new file mode 100644 index 0000000000000..c39c7fe41509f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_schema.ts @@ -0,0 +1,54 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import typeDetect from 'type-detect'; +import { MAX_SPACE_INITIALS } from '../../common'; + +export const SPACE_ID_REGEX = /^[a-z0-9_\-]+$/; + +export const spaceSchema = schema.object({ + id: schema.any({ + validate: value => { + const valueType = typeDetect(value); + if (valueType !== 'string') { + return `expected value of type [string] but got [${valueType}]`; + } + + if (!SPACE_ID_REGEX.test(value)) { + return `must be lower case, a-z, 0-9, '_', and '-' are allowed`; + } + }, + }), + name: schema.string({ minLength: 1 }), + description: schema.maybe(schema.string()), + initials: schema.maybe(schema.string({ maxLength: MAX_SPACE_INITIALS })), + color: schema.maybe( + schema.any({ + validate: value => { + const valueType = typeDetect(value); + if (valueType !== 'string') { + return `expected value of type [string] but got [${valueType}]`; + } + + if (!/^#[a-zA-Z0-9]{6}$/.test(value)) { + return `must be a 6 digit hex color, starting with a #`; + } + }, + }) + ), + disabledFeatures: schema.arrayOf(schema.string(), { defaultValue: [] }), + _reserved: schema.maybe(schema.boolean()), + imageUrl: schema.maybe( + schema.string({ + validate: value => { + if (value !== '' && !/^data:image.*$/.test(value)) { + return `must start with 'data:image'`; + } + }, + }) + ), +}); diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap rename to x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts b/x-pack/plugins/spaces/server/lib/spaces_client/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/index.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts similarity index 96% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts index d773cd86ef688..10f6292abf319 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts @@ -29,7 +29,7 @@ const createSpacesClientMock = () => create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), delete: jest.fn(), - } as unknown) as SpacesClient); + } as unknown) as jest.Mocked); export const spacesClientMock = { create: createSpacesClientMock, diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts similarity index 99% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 78ad10bbd9164..ce6f98c4af74e 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -5,9 +5,9 @@ */ import { SpacesClient } from './spaces_client'; -import { AuthorizationService } from '../../../../security/server/lib/authorization/service'; -import { actionsFactory } from '../../../../security/server/lib/authorization/actions'; -import { SpacesConfigType, config } from '../../new_platform/config'; +import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; +import { actionsFactory } from '../../../../../legacy/plugins/security/server/lib/authorization/actions'; +import { ConfigType, ConfigSchema } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; const createMockAuditLogger = () => { @@ -69,8 +69,8 @@ const createMockAuthorization = () => { }; }; -const createMockConfig = (mockConfig: SpacesConfigType = { maxSpaces: 1000 }) => { - return config.schema.validate(mockConfig); +const createMockConfig = (mockConfig: ConfigType = { maxSpaces: 1000 }) => { + return ConfigSchema.validate(mockConfig); }; describe('#getAll', () => { diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts similarity index 97% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts rename to x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 6d30084d0dc86..052534879e678 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -7,11 +7,11 @@ import Boom from 'boom'; import { omit } from 'lodash'; import { Legacy } from 'kibana'; import { KibanaRequest } from 'src/core/server'; -import { AuthorizationService } from '../../../../security/server/lib/authorization/service'; +import { AuthorizationService } from '../../../../../legacy/plugins/security/server/lib/authorization/service'; import { isReservedSpace } from '../../../common/is_reserved_space'; import { Space } from '../../../common/model/space'; import { SpacesAuditLogger } from '../audit_logger'; -import { SpacesConfigType } from '../../new_platform/config'; +import { ConfigType } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; type SpacesClientRequestFacade = Legacy.Request | KibanaRequest; @@ -33,7 +33,7 @@ export class SpacesClient { private readonly debugLogger: (message: string) => void, private readonly authorization: AuthorizationService | null, private readonly callWithRequestSavedObjectRepository: any, - private readonly config: SpacesConfigType, + private readonly config: ConfigType, private readonly internalSavedObjectRepository: any, private readonly request: SpacesClientRequestFacade ) {} diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts similarity index 84% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts rename to x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts index eed72ec744e0f..c6188058ae365 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -7,13 +7,13 @@ import * as Rx from 'rxjs'; import { DEFAULT_SPACE_ID } from '../../common/constants'; import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory'; -import { SpacesService } from '../new_platform/spaces_service'; +import { SpacesService } from '../spaces_service'; import { SavedObjectsService } from 'src/core/server'; import { SpacesAuditLogger } from './audit_logger'; -import { elasticsearchServiceMock, coreMock } from '../../../../../../src/core/server/mocks'; -import { spacesServiceMock } from '../new_platform/spaces_service/spaces_service.mock'; -import { createOptionalPlugin } from '../../../../server/lib/optional_plugin'; -import { LegacyAPI } from '../new_platform/plugin'; +import { elasticsearchServiceMock, coreMock } from '../../../../../src/core/server/mocks'; +import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; +import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; +import { LegacyAPI } from '../plugin'; const log = { log: jest.fn(), @@ -56,7 +56,8 @@ describe('createSpacesTutorialContextFactory', () => { const spacesService = await service.setup({ http: coreMock.createSetup().http, elasticsearch: elasticsearchServiceMock.createSetupContract(), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), getSpacesAuditLogger: () => ({} as SpacesAuditLogger), config$: Rx.of({ maxSpaces: 1000 }), }); diff --git a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts rename to x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts index 770294840f1c6..f89681b709949 100644 --- a/x-pack/legacy/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SpacesServiceSetup } from '../new_platform/spaces_service/spaces_service'; +import { SpacesServiceSetup } from '../spaces_service/spaces_service'; export function createSpacesTutorialContextFactory(spacesService: SpacesServiceSetup) { return function spacesTutorialContextFactory(request: any) { diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts new file mode 100644 index 0000000000000..5878272c84924 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; +import { addSpaceIdToPath, getSpaceIdFromPath } from './spaces_url_parser'; + +describe('getSpaceIdFromPath', () => { + describe('without a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath)).toEqual('my-awesome-space-lives-here'); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath)).toEqual(DEFAULT_SPACE_ID); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceIdFromPath(basePath)).toEqual(DEFAULT_SPACE_ID); + }); + }); + + describe('with a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/')).toEqual('my-awesome-space-lives-here'); + }); + + test('it identifies the space url context following the server base path', () => { + const basePath = `/server-base-path-here/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/server-base-path-here')).toEqual( + 'my-awesome-space-lives-here' + ); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/this/is/a')).toEqual(DEFAULT_SPACE_ID); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceIdFromPath(basePath, basePath)).toEqual(DEFAULT_SPACE_ID); + }); + }); +}); + +describe('addSpaceIdToPath', () => { + test('handles no parameters', () => { + expect(addSpaceIdToPath()).toEqual(`/`); + }); + + test('it adds to the basePath correctly', () => { + expect(addSpaceIdToPath('/my/base/path', 'url-context')).toEqual('/my/base/path/s/url-context'); + }); + + test('it appends the requested path to the end of the url context', () => { + expect(addSpaceIdToPath('/base', 'context', '/final/destination')).toEqual( + '/base/s/context/final/destination' + ); + }); + + test('it throws an error when the requested path does not start with a slash', () => { + expect(() => { + addSpaceIdToPath('', '', 'foo'); + }).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts new file mode 100644 index 0000000000000..14113cbf9d807 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts @@ -0,0 +1,47 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; + +export function getSpaceIdFromPath( + requestBasePath: string = '/', + serverBasePath: string = '/' +): string { + let pathToCheck: string = requestBasePath; + + if (serverBasePath && serverBasePath !== '/' && requestBasePath.startsWith(serverBasePath)) { + pathToCheck = requestBasePath.substr(serverBasePath.length); + } + // Look for `/s/space-url-context` in the base path + const matchResult = pathToCheck.match(/^\/s\/([a-z0-9_\-]+)/); + + if (!matchResult || matchResult.length === 0) { + return DEFAULT_SPACE_ID; + } + + // Ignoring first result, we only want the capture group result at index 1 + const [, spaceId] = matchResult; + + if (!spaceId) { + throw new Error(`Unable to determine Space ID from request path: ${requestBasePath}`); + } + + return spaceId; +} + +export function addSpaceIdToPath( + basePath: string = '/', + spaceId: string = '', + requestedPath: string = '' +): string { + if (requestedPath && !requestedPath.startsWith('/')) { + throw new Error(`path must start with a /`); + } + + if (spaceId && spaceId !== DEFAULT_SPACE_ID) { + return `${basePath}/s/${spaceId}${requestedPath}`; + } + return `${basePath}${requestedPath}`; +} diff --git a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts new file mode 100644 index 0000000000000..f6133f5c03c6b --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { UICapabilities } from 'ui/capabilities'; +import { Feature } from '../../../../plugins/features/server'; +import { Space } from '../../common/model/space'; +import { toggleUICapabilities } from './toggle_ui_capabilities'; + +const features: Feature[] = [ + { + id: 'feature_1', + name: 'Feature 1', + app: [], + privileges: {}, + }, + { + id: 'feature_2', + name: 'Feature 2', + navLinkId: 'feature2', + app: [], + catalogue: ['feature2Entry'], + management: { + kibana: ['somethingElse'], + }, + privileges: { + all: { + app: [], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + }, + }, + { + id: 'feature_3', + name: 'Feature 3', + navLinkId: 'feature3', + app: [], + catalogue: ['feature3Entry'], + management: { + kibana: ['indices'], + }, + privileges: { + all: { + app: [], + ui: [], + savedObject: { + all: [], + read: [], + }, + }, + }, + }, +]; + +const buildUiCapabilities = () => + Object.freeze({ + navLinks: { + feature1: true, + feature2: true, + feature3: true, + unknownFeature: true, + }, + catalogue: { + discover: true, + visualize: false, + }, + management: { + kibana: { + settings: false, + indices: true, + somethingElse: true, + }, + }, + feature_1: { + foo: true, + bar: true, + }, + feature_2: { + foo: true, + bar: true, + }, + feature_3: { + foo: true, + bar: true, + }, + }) as UICapabilities; + +describe('toggleUiCapabilities', () => { + it('does not toggle capabilities when the space has no disabled features', () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: [], + }; + + const uiCapabilities: UICapabilities = buildUiCapabilities(); + const result = toggleUICapabilities(features, uiCapabilities, space); + expect(result).toEqual(buildUiCapabilities()); + }); + + it('ignores unknown disabledFeatures', () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: ['i-do-not-exist'], + }; + + const uiCapabilities: UICapabilities = buildUiCapabilities(); + const result = toggleUICapabilities(features, uiCapabilities, space); + expect(result).toEqual(buildUiCapabilities()); + }); + + it('disables the corresponding navLink, catalogue, management sections, and all capability flags for disabled features', () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: ['feature_2'], + }; + + const uiCapabilities: UICapabilities = buildUiCapabilities(); + const result = toggleUICapabilities(features, uiCapabilities, space); + + const expectedCapabilities = buildUiCapabilities(); + + expectedCapabilities.navLinks.feature2 = false; + expectedCapabilities.catalogue.feature2Entry = false; + expectedCapabilities.management.kibana.somethingElse = false; + expectedCapabilities.feature_2.bar = false; + expectedCapabilities.feature_2.foo = false; + + expect(result).toEqual(expectedCapabilities); + }); + + it('can disable everything', () => { + const space: Space = { + id: 'space', + name: '', + disabledFeatures: ['feature_1', 'feature_2', 'feature_3'], + }; + + const uiCapabilities: UICapabilities = buildUiCapabilities(); + const result = toggleUICapabilities(features, uiCapabilities, space); + + const expectedCapabilities = buildUiCapabilities(); + + expectedCapabilities.feature_1.bar = false; + expectedCapabilities.feature_1.foo = false; + + expectedCapabilities.navLinks.feature2 = false; + expectedCapabilities.catalogue.feature2Entry = false; + expectedCapabilities.management.kibana.somethingElse = false; + expectedCapabilities.feature_2.bar = false; + expectedCapabilities.feature_2.foo = false; + + expectedCapabilities.navLinks.feature3 = false; + expectedCapabilities.catalogue.feature3Entry = false; + expectedCapabilities.management.kibana.indices = false; + expectedCapabilities.feature_3.bar = false; + expectedCapabilities.feature_3.foo = false; + + expect(result).toEqual(expectedCapabilities); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts new file mode 100644 index 0000000000000..63f52ac379300 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/toggle_ui_capabilities.ts @@ -0,0 +1,71 @@ +/* + * 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 _ from 'lodash'; +import { UICapabilities } from 'ui/capabilities'; +import { Feature } from '../../../../plugins/features/server'; +import { Space } from '../../common/model/space'; + +export function toggleUICapabilities( + features: Feature[], + uiCapabilities: UICapabilities, + activeSpace: Space +) { + const clonedCapabilities = _.cloneDeep(uiCapabilities); + + toggleDisabledFeatures(features, clonedCapabilities, activeSpace); + + return clonedCapabilities; +} + +function toggleDisabledFeatures( + features: Feature[], + uiCapabilities: UICapabilities, + activeSpace: Space +) { + const disabledFeatureKeys: string[] = activeSpace.disabledFeatures; + + const disabledFeatures: Feature[] = disabledFeatureKeys + .map(key => features.find(feature => feature.id === key)) + .filter(feature => typeof feature !== 'undefined') as Feature[]; + + const navLinks: Record = uiCapabilities.navLinks; + const catalogueEntries: Record = uiCapabilities.catalogue; + const managementItems: Record> = uiCapabilities.management; + + for (const feature of disabledFeatures) { + // Disable associated navLink, if one exists + if (feature.navLinkId && navLinks.hasOwnProperty(feature.navLinkId)) { + navLinks[feature.navLinkId] = false; + } + + // Disable associated catalogue entries + const privilegeCatalogueEntries: string[] = feature.catalogue || []; + privilegeCatalogueEntries.forEach(catalogueEntryId => { + catalogueEntries[catalogueEntryId] = false; + }); + + // Disable associated management items + const privilegeManagementSections: Record = feature.management || {}; + Object.entries(privilegeManagementSections).forEach(([sectionId, sectionItems]) => { + sectionItems.forEach(item => { + if ( + managementItems.hasOwnProperty(sectionId) && + managementItems[sectionId].hasOwnProperty(item) + ) { + managementItems[sectionId][item] = false; + } + }); + }); + + // Disable "sub features" that match the disabled feature + if (uiCapabilities.hasOwnProperty(feature.id)) { + const capability = uiCapabilities[feature.id]; + Object.keys(capability).forEach(featureKey => { + capability[featureKey] = false; + }); + } + } +} diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/namespace.test.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/namespace.test.ts rename to x-pack/plugins/spaces/server/lib/utils/namespace.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/namespace.ts b/x-pack/plugins/spaces/server/lib/utils/namespace.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/namespace.ts rename to x-pack/plugins/spaces/server/lib/utils/namespace.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/url.test.ts b/x-pack/plugins/spaces/server/lib/utils/url.test.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/url.test.ts rename to x-pack/plugins/spaces/server/lib/utils/url.test.ts diff --git a/x-pack/legacy/plugins/spaces/server/lib/utils/url.ts b/x-pack/plugins/spaces/server/lib/utils/url.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/lib/utils/url.ts rename to x-pack/plugins/spaces/server/lib/utils/url.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts similarity index 63% rename from x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts rename to x-pack/plugins/spaces/server/plugin.ts index 382227a4f1cec..3479567107185 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -4,32 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Observable } from 'rxjs'; +import { Observable, combineLatest, Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; import { SavedObjectsService, CoreSetup } from 'src/core/server'; import { Logger, PluginInitializerContext } from 'src/core/server'; import { CapabilitiesModifier } from 'src/legacy/server/capabilities'; -import { Legacy } from 'kibana'; -import { OptionalPlugin } from '../../../../server/lib/optional_plugin'; -import { XPackMainPlugin } from '../../../xpack_main/xpack_main'; -import { createDefaultSpace } from '../lib/create_default_space'; +import { SecurityPlugin } from '../../../legacy/plugins/security'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { OptionalPlugin } from '../../../legacy/server/lib/optional_plugin'; +import { XPackMainPlugin } from '../../../legacy/plugins/xpack_main/xpack_main'; +import { createDefaultSpace } from './lib/create_default_space'; // @ts-ignore import { AuditLogger } from '../../../../server/lib/audit_logger'; -// @ts-ignore -import { watchStatusAndLicenseToInitialize } from '../../../../server/lib/watch_status_and_license_to_initialize'; -import { checkLicense } from '../lib/check_license'; -import { spacesSavedObjectsClientWrapperFactory } from '../lib/saved_objects_client/saved_objects_client_wrapper_factory'; -import { SpacesAuditLogger } from '../lib/audit_logger'; -import { createSpacesTutorialContextFactory } from '../lib/spaces_tutorial_context_factory'; -import { initInternalApis } from '../routes/api/v1'; -import { initExternalSpacesApi } from '../routes/api/external'; -import { getSpacesUsageCollector } from '../lib/get_spaces_usage_collector'; + +import { spacesSavedObjectsClientWrapperFactory } from './lib/saved_objects_client/saved_objects_client_wrapper_factory'; +import { SpacesAuditLogger } from './lib/audit_logger'; +import { createSpacesTutorialContextFactory } from './lib/spaces_tutorial_context_factory'; +import { initInternalApis } from './routes/api/internal'; +import { getSpacesUsageCollector } from './lib/get_spaces_usage_collector'; import { SpacesService } from './spaces_service'; -import { SecurityPlugin } from '../../../security'; import { SpacesServiceSetup } from './spaces_service/spaces_service'; -import { SpacesConfigType } from './config'; -import { getActiveSpace } from '../lib/get_active_space'; -import { toggleUICapabilities } from '../lib/toggle_ui_capabilities'; -import { initSpacesRequestInterceptors } from '../lib/request_interceptors'; +import { ConfigType } from './config'; +import { getActiveSpace } from './lib/get_active_space'; +import { toggleUICapabilities } from './lib/toggle_ui_capabilities'; +import { initSpacesRequestInterceptors } from './lib/request_interceptors'; +import { initExternalSpacesApi } from './routes/api/external'; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin @@ -56,16 +56,15 @@ export interface LegacyAPI { serverBasePath: string; serverDefaultRoute: string; }; - router: Legacy.Server['route']; -} - -export interface PluginsSetup { + xpackMain: XPackMainPlugin; // TODO: Spaces has a circular dependency with Security right now. // Security is not yet available when init runs, so this is wrapped in an optional plugin for the time being. security: OptionalPlugin; - xpackMain: XPackMainPlugin; - // TODO: this is temporary for `watchLicenseAndStatusToInitialize` - spaces: any; +} + +export interface PluginsSetup { + features: FeaturesPluginSetup; + licensing: LicensingPluginSetup; } export interface SpacesPluginSetup { @@ -76,10 +75,12 @@ export interface SpacesPluginSetup { export class Plugin { private readonly pluginId = 'spaces'; - private readonly config$: Observable; + private readonly config$: Observable; private readonly log: Logger; + private statusAndLicense$?: Subscription; + private legacyAPI?: LegacyAPI; private readonly getLegacyAPI = () => { if (!this.legacyAPI) { @@ -99,60 +100,95 @@ export class Plugin { }; constructor(initializerContext: PluginInitializerContext) { - this.config$ = initializerContext.config.create(); + this.config$ = initializerContext.config.create(); this.log = initializerContext.logger.get(); } - public async setup(core: CoreSetup, plugins: PluginsSetup): Promise { - const xpackMainPlugin: XPackMainPlugin = plugins.xpackMain; - watchStatusAndLicenseToInitialize(xpackMainPlugin, plugins.spaces, async () => { - await createDefaultSpace({ - elasticsearch: core.elasticsearch, - savedObjects: this.getLegacyAPI().savedObjects, - }); - }); + public async start() {} - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin. - xpackMainPlugin.info.feature(this.pluginId).registerLicenseCheckResultsGenerator(checkLicense); + public async setup(core: CoreSetup, plugins: PluginsSetup): Promise { + // TODO: checkLicense replacement const service = new SpacesService(this.log, this.getLegacyAPI); const spacesService = await service.setup({ http: core.http, elasticsearch: core.elasticsearch, - security: plugins.security, + getSecurity: () => this.getLegacyAPI().security, getSpacesAuditLogger: this.getSpacesAuditLogger, config$: this.config$, }); + const externalRouter = core.http.createRouter(); + initExternalSpacesApi({ + externalRouter, + log: this.log, + getSavedObjects: () => this.getLegacyAPI().savedObjects, + spacesService, + }); + + const internalRouter = core.http.createRouter(); + initInternalApis({ + internalRouter, + getLegacyAPI: this.getLegacyAPI, + spacesService, + }); + + initSpacesRequestInterceptors({ + http: core.http, + log: this.log, + getLegacyAPI: this.getLegacyAPI, + spacesService, + features: plugins.features, + }); + return { spacesService, registerLegacyAPI: (legacyAPI: LegacyAPI) => { this.legacyAPI = legacyAPI; - this.setupLegacyComponents(core, spacesService, plugins.xpackMain); + this.statusAndLicense$ = combineLatest( + core.elasticsearch.adminClient$, + plugins.licensing.license$ + ) + .pipe( + tap(([esClient, licenseCheck]) => { + if (licenseCheck.isAvailable) { + createDefaultSpace({ + esClient, + savedObjects: this.getLegacyAPI().savedObjects, + }); + } + }) + ) + .subscribe(); + this.setupLegacyComponents(core, spacesService, plugins.features, plugins.licensing); }, }; } + public stop() { + if (this.statusAndLicense$) { + this.statusAndLicense$.unsubscribe(); + this.statusAndLicense$ = undefined; + } + } + private setupLegacyComponents( core: CoreSetup, spacesService: SpacesServiceSetup, - xpackMainPlugin: XPackMainPlugin + featuresSetup: FeaturesPluginSetup, + licensingSetup: LicensingPluginSetup ) { const legacyAPI = this.getLegacyAPI(); - const { addScopedSavedObjectsClientWrapperFactory, types } = legacyAPI.savedObjects; addScopedSavedObjectsClientWrapperFactory( Number.MIN_SAFE_INTEGER, 'spaces', spacesSavedObjectsClientWrapperFactory(spacesService, types) ); - legacyAPI.tutorial.addScopedTutorialContextFactory( createSpacesTutorialContextFactory(spacesService) ); - legacyAPI.capabilities.registerCapabilitiesModifier(async (request, uiCapabilities) => { const spacesClient = await spacesService.scopedClient(request); try { @@ -161,44 +197,20 @@ export class Plugin { core.http.basePath.get(request), legacyAPI.legacyConfig.serverBasePath ); - - const features = xpackMainPlugin.getFeatures(); + const features = featuresSetup.getFeatures(); return toggleUICapabilities(features, uiCapabilities, activeSpace); } catch (e) { return uiCapabilities; } }); - // Register a function with server to manage the collection of usage stats legacyAPI.usage.collectorSet.register( getSpacesUsageCollector({ kibanaIndex: legacyAPI.legacyConfig.kibanaIndex, usage: legacyAPI.usage, - xpackMain: xpackMainPlugin, + features: featuresSetup, + licensing: licensingSetup, }) ); - - initInternalApis({ - legacyRouter: legacyAPI.router, - getLegacyAPI: this.getLegacyAPI, - spacesService, - xpackMain: xpackMainPlugin, - }); - - initExternalSpacesApi({ - legacyRouter: legacyAPI.router, - log: this.log, - savedObjects: legacyAPI.savedObjects, - spacesService, - xpackMain: xpackMainPlugin, - }); - - initSpacesRequestInterceptors({ - http: core.http, - log: this.log, - getLegacyAPI: this.getLegacyAPI, - spacesService, - xpackMain: xpackMainPlugin, - }); } } diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts new file mode 100644 index 0000000000000..4aaf07b8bc001 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_legacy_api.ts @@ -0,0 +1,121 @@ +/* + * 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 { Readable } from 'stream'; +import { createPromiseFromStreams, createConcatStream } from 'src/legacy/utils/streams'; +import { SavedObjectsSchema, SavedObjectsService } from 'src/core/server'; +import { LegacyAPI } from '../../../plugin'; +import { Space } from '../../../../common/model/space'; +import { createSpaces } from '.'; + +async function readStreamToCompletion(stream: Readable) { + return (createPromiseFromStreams([stream, createConcatStream([])]) as unknown) as any[]; +} + +interface LegacyAPIOpts { + spaces?: Space[]; +} + +export const createLegacyAPI = ({ + spaces = createSpaces().map(s => ({ id: s.id, ...s.attributes })), +}: LegacyAPIOpts = {}) => { + const mockSavedObjectsClientContract = { + get: jest.fn((type, id) => { + const result = spaces.filter(s => s.id === id); + if (!result.length) { + throw new Error(`not found: [${type}:${id}]`); + } + return result[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn((type, attributes, { id }) => { + if (spaces.find(s => s.id === id)) { + throw new Error('conflict'); + } + return {}; + }), + update: jest.fn((type, id) => { + if (!spaces.find(s => s.id === id)) { + throw new Error('not found: during update'); + } + return {}; + }), + delete: jest.fn((type: string, id: string) => { + return {}; + }), + deleteByNamespace: jest.fn(), + }; + + const savedObjectsService = ({ + types: ['visualization', 'dashboard', 'index-pattern', 'globalType'], + schema: new SavedObjectsSchema({ + space: { + isNamespaceAgnostic: true, + hidden: true, + }, + globalType: { + isNamespaceAgnostic: true, + }, + }), + getScopedSavedObjectsClient: jest.fn().mockResolvedValue(mockSavedObjectsClientContract), + importExport: { + objectLimit: 10000, + getSortedObjectsForExport: jest.fn().mockResolvedValue( + new Readable({ + objectMode: true, + read() { + // if (Array.isArray(payload.objects)) { + // payload.objects.forEach((o: any) => this.push(o)); + // } + this.push(null); + }, + }) + ), + importSavedObjects: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + resolveImportErrors: jest.fn().mockImplementation(async (opts: Record) => { + const objectsToImport: any[] = await readStreamToCompletion(opts.readStream); + return { + success: true, + successCount: objectsToImport.length, + }; + }), + }, + SavedObjectsClient: { + errors: { + isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), + isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')), + }, + }, + } as unknown) as jest.Mocked; + + const legacyAPI: jest.Mocked = { + legacyConfig: { + kibanaIndex: '', + serverBasePath: '', + serverDefaultRoute: '/app/kibana', + }, + auditLogger: {} as any, + capabilities: {} as any, + security: {} as any, + tutorial: {} as any, + usage: {} as any, + xpackMain: {} as any, + savedObjects: savedObjectsService, + }; + + return legacyAPI; +}; diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts new file mode 100644 index 0000000000000..1548a88e554e3 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -0,0 +1,43 @@ +/* + * 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 { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; + +export const createMockSavedObjectsRepository = (spaces: any[] = []) => { + const mockSavedObjectsClientContract = ({ + get: jest.fn((type, id) => { + const result = spaces.filter(s => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return result[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn((type, attributes, { id }) => { + if (spaces.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); + } + return {}; + }), + update: jest.fn((type, id) => { + if (!spaces.find(s => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return {}; + }), + delete: jest.fn((type: string, id: string) => { + return {}; + }), + deleteByNamespace: jest.fn(), + } as unknown) as jest.Mocked; + + return mockSavedObjectsClientContract; +}; diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts similarity index 86% rename from x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts rename to x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts index 85284e3fc3a1c..0e23054819ea5 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts @@ -10,18 +10,21 @@ export function createSpaces() { id: 'a-space', attributes: { name: 'a space', + disabledFeatures: [], }, }, { id: 'b-space', attributes: { name: 'b space', + disabledFeatures: [], }, }, { id: 'default', attributes: { name: 'Default Space', + disabledFeatures: [], _reserved: true, }, }, diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts similarity index 66% rename from x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts rename to x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts index 37fe32c80032e..1f7caf9003018 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts @@ -5,11 +5,5 @@ */ export { createSpaces } from './create_spaces'; -export { - createTestHandler, - TestConfig, - TestOptions, - TeardownFn, - RequestRunner, - RequestRunnerResult, -} from './create_test_handler'; +export { createLegacyAPI } from './create_legacy_api'; +export { createMockSavedObjectsRepository } from './create_mock_so_repository'; diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts new file mode 100644 index 0000000000000..58b29f09380fc --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -0,0 +1,382 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initCopyToSpacesApi } from './copy_to_space'; + +jest.setTimeout(30000); +describe('copy to space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const legacyAPI = createLegacyAPI({ spaces }); + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initCopyToSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log: loggingServiceMock.create().get('spaces'), + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /api/spaces/_copy_saved_objects', () => { + test.todo(`returns result of routePreCheckLicense`); + + test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + spaces: ['a-space'], + objects: [], + }; + + await kbnTestServer.request.post(root, '/api/spaces/_copy_saved_objects').send(payload); + + expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + test(`requires space IDs to be unique`, async () => { + const payload = { + spaces: ['a-space', 'a-space'], + objects: [], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_copy_saved_objects') + .send(payload); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "[request body.spaces]: duplicate space ids are not allowed", + "statusCode": 400, + } + `); + }); + + test(`requires well-formed space IDS`, async () => { + const payload = { + spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], + objects: [], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_copy_saved_objects') + .send(payload); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "[request body.spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed", + "statusCode": 400, + } + `); + }); + + test(`requires objects to be unique`, async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_copy_saved_objects') + .send(payload); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "[request body.objects]: duplicate objects are not allowed", + "statusCode": 400, + } + `); + }); + + test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + const payload = { + spaces: ['a-space'], + objects: [{ type: 'globalType', id: 'bar' }, { type: 'visualization', id: 'bar' }], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_copy_saved_objects') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(1); + const [importCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[0]; + + expect(importCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + test('copies to multiple spaces', async () => { + const payload = { + spaces: ['a-space', 'b-space'], + objects: [{ type: 'visualization', id: 'bar' }], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_copy_saved_objects') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.importSavedObjects).toHaveBeenCalledTimes(2); + const [firstImportCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[0]; + + expect(firstImportCallOptions).toMatchObject({ + namespace: 'a-space', + }); + + const [secondImportCallOptions] = (legacyAPI.savedObjects.importExport + .importSavedObjects as any).mock.calls[1]; + + expect(secondImportCallOptions).toMatchObject({ + namespace: 'b-space', + }); + }); + }); + + describe('POST /api/spaces/_resolve_copy_saved_objects_errors', () => { + test.todo(`returns result of routePreCheckLicense`); + + test(`uses a Saved Objects Client instance without the spaces wrapper`, async () => { + const payload = { + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'visualization', id: 'bar' }], + }; + + await kbnTestServer.request + .post(root, '/api/spaces/_resolve_copy_saved_objects_errors') + .send(payload); + + expect(legacyAPI.savedObjects.getScopedSavedObjectsClient).toHaveBeenCalledWith( + expect.any(Object), + { + excludedWrappers: ['spaces'], + } + ); + }); + + it(`requires objects to be unique`, async () => { + const payload = { + retries: {}, + objects: [{ type: 'foo', id: 'bar' }, { type: 'foo', id: 'bar' }], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_resolve_copy_saved_objects_errors') + .send(payload); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "[request body.objects]: duplicate objects are not allowed", + "statusCode": 400, + } + `); + }); + + test(`requires well-formed space ids`, async () => { + const payload = { + retries: { + ['invalid-space-id!@#$%^&*()']: [ + { + type: 'foo', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [{ type: 'foo', id: 'bar' }], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_resolve_copy_saved_objects_errors') + .send(payload); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toMatchInlineSnapshot(` + Object { + "error": "Bad Request", + "message": "[request body.retries]: Invalid space id: invalid-space-id!@#$%^&*()", + "statusCode": 400, + } + `); + }); + + test('does not allow namespace agnostic types to be copied (via "supportedTypes" property)', async () => { + const payload = { + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + { + type: 'globalType', + id: 'bar', + overwrite: true, + }, + ], + }, + objects: [ + { + type: 'globalType', + id: 'bar', + }, + { type: 'visualization', id: 'bar' }, + ], + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_resolve_copy_saved_objects_errors') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(1); + const [resolveImportErrorsCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[0]; + + expect(resolveImportErrorsCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + + test('resolves conflicts for multiple spaces', async () => { + const payload = { + objects: [{ type: 'visualization', id: 'bar' }], + retries: { + ['a-space']: [ + { + type: 'visualization', + id: 'bar', + overwrite: true, + }, + ], + ['b-space']: [ + { + type: 'globalType', + id: 'bar', + overwrite: true, + }, + ], + }, + }; + + const response = await kbnTestServer.request + .post(root, '/api/spaces/_resolve_copy_saved_objects_errors') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(legacyAPI.savedObjects.importExport.resolveImportErrors).toHaveBeenCalledTimes(2); + const [resolveImportErrorsFirstCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[0]; + + expect(resolveImportErrorsFirstCallOptions).toMatchObject({ + namespace: 'a-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + + const [resolveImportErrorsSecondCallOptions] = (legacyAPI.savedObjects.importExport + .resolveImportErrors as any).mock.calls[1]; + + expect(resolveImportErrorsSecondCallOptions).toMatchObject({ + namespace: 'b-space', + supportedTypes: ['visualization', 'dashboard', 'index-pattern'], + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts new file mode 100644 index 0000000000000..04ead0922d437 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.ts @@ -0,0 +1,177 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import _ from 'lodash'; +import { + copySavedObjectsToSpacesFactory, + resolveCopySavedObjectsToSpacesConflictsFactory, +} from '../../../lib/copy_to_spaces'; +import { ExternalRouteDeps } from '.'; +import { COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS } from '../../../lib/copy_to_spaces/copy_to_spaces'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; + +interface CopyPayload { + spaces: string[]; + objects: Array<{ type: string; id: string }>; + includeReferences: boolean; + overwrite: boolean; +} + +interface ResolveConflictsPayload { + objects: Array<{ type: string; id: string }>; + includeReferences: boolean; + retries: { + [spaceId: string]: Array<{ + type: string; + id: string; + overwrite: boolean; + }>; + }; +} + +const areObjectsUnique = (objects: any[]) => + _.uniq(objects, (o: any) => `${o.type}:${o.id}`).length === objects.length; + +export function initCopyToSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, spacesService, getSavedObjects } = deps; + + externalRouter.post( + { + path: '/api/spaces/_copy_saved_objects', + validate: { + body: schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: value => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed`; + } + }, + }), + { + validate: spaceIds => { + if (_.uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: objects => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + overwrite: schema.boolean({ defaultValue: false }), + }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + const copySavedObjectsToSpaces = copySavedObjectsToSpacesFactory( + savedObjectsClient, + getSavedObjects() + ); + const { + spaces: destinationSpaceIds, + objects, + includeReferences, + overwrite, + } = request.body as CopyPayload; + const sourceSpaceId = spacesService.getSpaceId(request); + const copyResponse = await copySavedObjectsToSpaces(sourceSpaceId, destinationSpaceIds, { + objects, + includeReferences, + overwrite, + }); + return response.ok({ body: copyResponse }); + } + ); + + const retrySchema = schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + overwrite: schema.boolean({ defaultValue: false }), + }) + ); + externalRouter.post( + { + path: '/api/spaces/_resolve_copy_saved_objects_errors', + validate: { + body: schema.object({ + retries: schema.object( + {}, + { + allowUnknowns: true, + validate: retries => { + const invalidKey = Object.keys(retries).find(key => !SPACE_ID_REGEX.test(key)); + if (invalidKey) { + return `Invalid space id: ${invalidKey}`; + } + + for (const retry of Object.values(retries)) { + try { + retrySchema.validate(retry); + } catch (e) { + return e; + } + } + }, + } + ), + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { + validate: objects => { + if (!areObjectsUnique(objects)) { + return 'duplicate objects are not allowed'; + } + }, + } + ), + includeReferences: schema.boolean({ defaultValue: false }), + }), + }, + }, + async (context, request, response) => { + const savedObjectsClient = getSavedObjects().getScopedSavedObjectsClient( + request, + COPY_TO_SPACES_SAVED_OBJECTS_CLIENT_OPTS + ); + const resolveCopySavedObjectsToSpacesConflicts = resolveCopySavedObjectsToSpacesConflictsFactory( + savedObjectsClient, + getSavedObjects() + ); + const { objects, includeReferences, retries } = request.body as ResolveConflictsPayload; + const sourceSpaceId = spacesService.getSpaceId(request); + const resolveConflictsResponse = await resolveCopySavedObjectsToSpacesConflicts( + sourceSpaceId, + { + objects, + includeReferences, + retries, + } + ); + return response.ok({ body: resolveConflictsResponse }); + } + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts new file mode 100644 index 0000000000000..a13615933998b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -0,0 +1,102 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initDeleteSpacesApi } from './delete'; + +jest.setTimeout(30000); +describe('Spaces Public API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initDeleteSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log: loggingServiceMock.create().get('spaces'), + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + test(`DELETE spaces/{id}' deletes the space`, async () => { + const response = await kbnTestServer.request.delete(root, '/api/spaces/space/a-space'); + + const { status } = response; + + expect(status).toEqual(204); + }); + + test.todo(`returns result of routePreCheckLicense`); + + test('DELETE spaces/{id} throws when deleting a non-existent space', async () => { + const response = await kbnTestServer.request.delete(root, '/api/spaces/space/not-a-space'); + + const { status } = response; + + expect(status).toEqual(404); + }); + + test(`DELETE spaces/{id}' cannot delete reserved spaces`, async () => { + const response = await kbnTestServer.request.delete(root, '/api/spaces/space/default'); + + const { status, body } = response; + + expect(status).toEqual(400); + expect(body).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: 'This Space cannot be deleted because it is reserved.', + }); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.ts similarity index 52% rename from x-pack/legacy/plugins/spaces/server/routes/api/external/delete.ts rename to x-pack/plugins/spaces/server/routes/api/external/delete.ts index 720e932743e9a..6dffe22d06b4c 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/delete.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.ts @@ -4,38 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +import { schema } from '@kbn/config-schema'; import { wrapError } from '../../../lib/errors'; import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; +import { ExternalRouteDeps } from '.'; export function initDeleteSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, savedObjects, spacesService, routePreCheckLicenseFn } = deps; + const { externalRouter, getSavedObjects, spacesService } = deps; - legacyRouter({ - method: 'DELETE', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade, h: any) { - const { SavedObjectsClient } = savedObjects; + externalRouter.delete( + { + path: '/api/spaces/space/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + const { SavedObjectsClient } = getSavedObjects(); const spacesClient: SpacesClient = await spacesService.scopedClient(request); const id = request.params.id; - let result; - try { - result = await spacesClient.delete(id); + await spacesClient.delete(id); } catch (error) { if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); + return response.notFound(); } - return wrapError(error); + return response.customError(wrapError(error)); } - return h.response(result).code(204); - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); + return response.noContent(); + } + ); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts new file mode 100644 index 0000000000000..1831787578f9f --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -0,0 +1,109 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { initGetSpacesApi } from './get'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; + +jest.setTimeout(30000); +describe('GET spaces', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const legacyAPI = createLegacyAPI({ spaces }); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initGetSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log: loggingServiceMock.create().get('spaces'), + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + test(`'GET spaces' returns all available spaces`, async () => { + const response = await kbnTestServer.request.get(root, '/api/spaces/space'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(spaces); + }); + + test(`'GET spaces' returns all available spaces with the 'any' purpose`, async () => { + const response = await kbnTestServer.request.get(root, '/api/spaces/space?purpose=any'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(spaces); + }); + + test(`'GET spaces' returns all available spaces with the 'copySavedObjectsIntoSpace' purpose`, async () => { + const response = await kbnTestServer.request.get( + root, + '/api/spaces/space?purpose=copySavedObjectsIntoSpace' + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(spaces); + }); + + test.todo(`returns result of routePreCheckLicense`); + + test(`'GET spaces/{id}' returns the space with that id`, async () => { + const response = await kbnTestServer.request.get(root, '/api/spaces/space/default'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(spaces.find(s => s.id === 'default')); + }); + + test(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => { + const response = await kbnTestServer.request.get(root, '/api/spaces/space/not-a-space'); + + expect(response.status).toEqual(404); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts b/x-pack/plugins/spaces/server/routes/api/external/get.ts similarity index 54% rename from x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts rename to x-pack/plugins/spaces/server/routes/api/external/get.ts index 310cef5c1069e..91cf485b652f9 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/get.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.ts @@ -4,25 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import Joi from 'joi'; -import { RequestQuery } from 'hapi'; -import { GetSpacePurpose } from '../../../../common/model/types'; +import { schema } from '@kbn/config-schema'; import { Space } from '../../../../common/model/space'; +import { GetSpacePurpose } from '../../../../common/model/types'; import { wrapError } from '../../../lib/errors'; import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; +import { ExternalRouteDeps } from '.'; export function initGetSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps; + const { externalRouter, log, spacesService, getSavedObjects } = deps; - legacyRouter({ - method: 'GET', - path: '/api/spaces/space', - async handler(request: ExternalRouteRequestFacade) { + externalRouter.get( + { + path: '/api/spaces/space', + validate: { + query: schema.object({ + purpose: schema.maybe( + schema.oneOf([schema.literal('any'), schema.literal('copySavedObjectsIntoSpace')], { + defaultValue: 'any', + }) + ), + }), + }, + }, + async (context, request, response) => { log.debug(`Inside GET /api/spaces/space`); - const purpose: GetSpacePurpose = (request.query as RequestQuery).purpose as GetSpacePurpose; + const purpose = request.query.purpose as GetSpacePurpose; const spacesClient: SpacesClient = await spacesService.scopedClient(request); @@ -34,43 +42,37 @@ export function initGetSpacesApi(deps: ExternalRouteDeps) { log.debug(`Retrieved ${spaces.length} spaces for ${purpose} purpose`); } catch (error) { log.debug(`Error retrieving spaces for ${purpose} purpose: ${error}`); - return wrapError(error); + return response.customError(wrapError(error)); } - return spaces; - }, - options: { - pre: [routePreCheckLicenseFn], + return response.ok({ body: spaces }); + } + ); + + externalRouter.get( + { + path: '/api/spaces/space/{id}', validate: { - query: Joi.object().keys({ - purpose: Joi.string() - .valid('any', 'copySavedObjectsIntoSpace') - .default('any'), + params: schema.object({ + id: schema.string(), }), }, }, - }); - - legacyRouter({ - method: 'GET', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade) { + async (context, request, response) => { const spaceId = request.params.id; - const { SavedObjectsClient } = savedObjects; + const { SavedObjectsClient } = getSavedObjects(); const spacesClient: SpacesClient = await spacesService.scopedClient(request); try { - return await spacesClient.get(spaceId); + const space = await spacesClient.get(spaceId); + return response.ok({ body: space }); } catch (error) { if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); + return response.notFound(); } - return wrapError(error); + return response.customError(wrapError(error)); } - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); + } + ); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts new file mode 100644 index 0000000000000..a28035c422395 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -0,0 +1,31 @@ +/* + * 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 { Legacy } from 'kibana'; +import { Logger, SavedObjectsService, IRouter } from 'src/core/server'; +import { initDeleteSpacesApi } from './delete'; +import { initGetSpacesApi } from './get'; +import { initPostSpacesApi } from './post'; +import { initPutSpacesApi } from './put'; +import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; +import { initCopyToSpacesApi } from './copy_to_space'; + +export interface ExternalRouteDeps { + externalRouter: IRouter; + getSavedObjects: () => SavedObjectsService; + spacesService: SpacesServiceSetup; + log: Logger; +} + +export type ExternalRouteRequestFacade = Legacy.Request; + +export function initExternalSpacesApi(deps: ExternalRouteDeps) { + initDeleteSpacesApi(deps); + initGetSpacesApi(deps); + initPostSpacesApi(deps); + initPutSpacesApi(deps); + initCopyToSpacesApi(deps); +} diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts new file mode 100644 index 0000000000000..7b9da73f7bc2a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -0,0 +1,136 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initPostSpacesApi } from './post'; + +jest.setTimeout(30000); +describe('Spaces Public API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const legacyAPI = createLegacyAPI({ spaces }); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initPostSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log: loggingServiceMock.create().get('spaces'), + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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', + disabledFeatures: ['foo'], + }; + + const response = await kbnTestServer.request.post(root, '/api/spaces/space').send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description', disabledFeatures: ['foo'] }, + { id: 'my-space-id' } + ); + }); + + test.todo(`returns result of routePreCheckLicense`); + + 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 kbnTestServer.request.post(root, '/api/spaces/space').send(payload); + + const { status, body } = response; + + expect(status).toEqual(409); + expect(body).toEqual({ + error: 'Conflict', + message: 'space conflict', + statusCode: 409, + }); + }); + + test('POST /space should not require disabledFeatures to be specified', async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const response = await kbnTestServer.request.post(root, '/api/spaces/space').send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description', disabledFeatures: [] }, + { id: 'my-space-id' } + ); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts b/x-pack/plugins/spaces/server/routes/api/external/post.ts similarity index 57% rename from x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts rename to x-pack/plugins/spaces/server/routes/api/external/post.ts index 6a17b5c5eace6..30f89ac6afc4d 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/post.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.ts @@ -3,43 +3,44 @@ * 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 { spaceSchema } from '../../../lib/space_schema'; import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; +import { ExternalRouteDeps } from '.'; export function initPostSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, log, spacesService, savedObjects, routePreCheckLicenseFn } = deps; + const { externalRouter, log, spacesService, getSavedObjects } = deps; - legacyRouter({ - method: 'POST', - path: '/api/spaces/space', - async handler(request: ExternalRouteRequestFacade) { + externalRouter.post( + { + path: '/api/spaces/space', + validate: { + body: spaceSchema, + }, + }, + async (context, request, response) => { log.debug(`Inside POST /api/spaces/space`); - const { SavedObjectsClient } = savedObjects; + const { SavedObjectsClient } = getSavedObjects(); const spacesClient: SpacesClient = await spacesService.scopedClient(request); - const space = request.payload as Space; + const space = request.body as Space; try { log.debug(`Attempting to create space`); - return await spacesClient.create(space); + const createdSpace = await spacesClient.create(space); + return response.ok({ body: createdSpace }); } catch (error) { if (SavedObjectsClient.errors.isConflictError(error)) { - return Boom.conflict(`A space with the identifier ${space.id} already exists.`); + const { body } = wrapError( + Boom.conflict(`A space with the identifier ${space.id} already exists.`) + ); + return response.conflict({ body }); } log.debug(`Error creating space: ${error}`); - return wrapError(error); + return response.customError(wrapError(error)); } - }, - options: { - validate: { - payload: spaceSchema, - }, - pre: [routePreCheckLicenseFn], - }, - }); + } + ); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts new file mode 100644 index 0000000000000..fd96dc1a4363c --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -0,0 +1,161 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initPutSpacesApi } from './put'; + +jest.setTimeout(30000); +describe('Spaces Public API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const legacyAPI = createLegacyAPI({ spaces }); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initPutSpacesApi({ + externalRouter: router, + getSavedObjects: () => legacyAPI.savedObjects, + log: loggingServiceMock.create().get('spaces'), + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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', + disabledFeatures: [], + }; + + const response = await kbnTestServer.request + .put(root, '/api/spaces/space/a-space') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: 'with a description', + disabledFeatures: [], + }); + }); + + test('PUT /space should allow an empty description', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: '', + disabledFeatures: ['foo'], + }; + + const response = await kbnTestServer.request + .put(root, '/api/spaces/space/a-space') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: '', + disabledFeatures: ['foo'], + }); + }); + + test('PUT /space should not require disabledFeatures', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: '', + }; + + const response = await kbnTestServer.request + .put(root, '/api/spaces/space/a-space') + .send(payload); + + const { status } = response; + + expect(status).toEqual(200); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: '', + disabledFeatures: [], + }); + }); + + test.todo(`returns result of routePreCheckLicense`); + + 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 kbnTestServer.request + .put(root, '/api/spaces/space/a-new-space') + .send(payload); + + const { status } = response; + + expect(status).toEqual(404); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts b/x-pack/plugins/spaces/server/routes/api/external/put.ts similarity index 59% rename from x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts rename to x-pack/plugins/spaces/server/routes/api/external/put.ts index 8e0f7673358d0..e8136f0982b33 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/external/put.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.ts @@ -4,24 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +import { schema } from '@kbn/config-schema'; import { Space } from '../../../../common/model/space'; import { wrapError } from '../../../lib/errors'; import { spaceSchema } from '../../../lib/space_schema'; import { SpacesClient } from '../../../lib/spaces_client'; -import { ExternalRouteDeps, ExternalRouteRequestFacade } from '.'; +import { ExternalRouteDeps } from '.'; export function initPutSpacesApi(deps: ExternalRouteDeps) { - const { legacyRouter, spacesService, savedObjects, routePreCheckLicenseFn } = deps; + const { externalRouter, spacesService, getSavedObjects } = deps; - legacyRouter({ - method: 'PUT', - path: '/api/spaces/space/{id}', - async handler(request: ExternalRouteRequestFacade) { - const { SavedObjectsClient } = savedObjects; + externalRouter.put( + { + path: '/api/spaces/space/{id}', + validate: { + params: schema.object({ + id: schema.string(), + }), + body: spaceSchema, + }, + }, + async (context, request, response) => { + const { SavedObjectsClient } = getSavedObjects(); const spacesClient: SpacesClient = await spacesService.scopedClient(request); - const space: Space = request.payload as Space; + const space = request.body as Space; const id = request.params.id; let result: Space; @@ -29,18 +36,12 @@ export function initPutSpacesApi(deps: ExternalRouteDeps) { result = await spacesClient.update(id, { ...space }); } catch (error) { if (SavedObjectsClient.errors.isNotFoundError(error)) { - return Boom.notFound(); + return response.notFound(); } - return wrapError(error); + return response.customError(wrapError(error)); } - return result; - }, - options: { - validate: { - payload: spaceSchema, - }, - pre: [routePreCheckLicenseFn], - }, - }); + return response.ok({ body: result }); + } + ); } diff --git a/x-pack/plugins/spaces/server/routes/api/internal/index.ts b/x-pack/plugins/spaces/server/routes/api/internal/index.ts new file mode 100644 index 0000000000000..c5450956823fa --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/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 { IRouter } from 'src/core/server'; +import { initInternalSpacesApi } from './spaces'; +import { LegacyAPI } from '../../../plugin'; +import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; + +export interface InternalRouteDeps { + spacesService: SpacesServiceSetup; + getLegacyAPI(): LegacyAPI; + internalRouter: IRouter; +} + +export function initInternalApis(deps: InternalRouteDeps) { + initInternalSpacesApi(deps); +} diff --git a/x-pack/plugins/spaces/server/routes/api/internal/spaces.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/spaces.test.ts new file mode 100644 index 0000000000000..c2bc84f9f451c --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/spaces.test.ts @@ -0,0 +1,102 @@ +/* + * 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 * as Rx from 'rxjs'; +import * as kbnTestServer from '../../../../../../../src/test_utils/kbn_server'; +import { createSpaces, createLegacyAPI, createMockSavedObjectsRepository } from '../__fixtures__'; +import { CoreSetup } from 'src/core/server'; +import { loggingServiceMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { createOptionalPlugin } from '../../../../../../legacy/server/lib/optional_plugin'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initInternalSpacesApi } from './spaces'; + +jest.setTimeout(30000); +describe('Spaces API', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map(s => ({ id: s.id, ...s.attributes })); + + let root: ReturnType; + + const legacyAPI = createLegacyAPI({ spaces }); + + beforeAll(async () => { + root = kbnTestServer.createRoot(); + const { http } = await root.setup(); + const router = http.createRouter('/'); + + const log = loggingServiceMock.create().get('spaces'); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const service = new SpacesService(log, () => legacyAPI); + const spacesService = await service.setup({ + http: (http as unknown) as CoreSetup['http'], + elasticsearch: elasticsearchServiceMock.createSetupContract(), + getSecurity: () => + createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSpacesAuditLogger: () => ({} as SpacesAuditLogger), + config$: Rx.of({ maxSpaces: 1000 }), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + { maxSpaces: 1000 }, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initInternalSpacesApi({ + internalRouter: router, + getLegacyAPI: () => legacyAPI, + spacesService, + }); + + await root.start(); + }); + + afterAll(async () => await root.shutdown()); + + test('POST space/{id}/select should respond with the new space location', async () => { + const response = await kbnTestServer.request.post(root, '/api/spaces/v1/space/a-space/select'); + + const { status, body } = response; + expect(status).toEqual(200); + expect(body).toEqual({ location: '/s/a-space/app/kibana' }); + }); + + test.todo(`returns result of routePreCheckLicense`); + + test('POST space/{id}/select should respond with 404 when the space is not found', async () => { + const response = await kbnTestServer.request.post( + root, + '/api/spaces/v1/space/not-a-space/select' + ); + + const { status } = response; + + expect(status).toEqual(404); + }); + + test('POST space/{id}/select should respond with the new space location when a server.basePath is in use', async () => { + legacyAPI.legacyConfig.serverBasePath = '/my/base/path'; + + const response = await kbnTestServer.request.post(root, '/api/spaces/v1/space/a-space/select'); + + const { status, body } = response; + + expect(status).toEqual(200); + + expect(body).toEqual({ location: '/my/base/path/s/a-space/app/kibana' }); + }); +}); diff --git a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts b/x-pack/plugins/spaces/server/routes/api/internal/spaces.ts similarity index 67% rename from x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts rename to x-pack/plugins/spaces/server/routes/api/internal/spaces.ts index 3d15044d129e9..0442c8b402f45 100644 --- a/x-pack/legacy/plugins/spaces/server/routes/api/v1/spaces.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/spaces.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; +import { schema } from '@kbn/config-schema'; import { Space } from '../../../../common/model/space'; import { wrapError } from '../../../lib/errors'; import { SpacesClient } from '../../../lib/spaces_client'; @@ -13,12 +13,18 @@ import { getSpaceById } from '../../lib'; import { InternalRouteDeps } from '.'; export function initInternalSpacesApi(deps: InternalRouteDeps) { - const { legacyRouter, spacesService, getLegacyAPI, routePreCheckLicenseFn } = deps; + const { internalRouter, spacesService, getLegacyAPI } = deps; - legacyRouter({ - method: 'POST', - path: '/api/spaces/v1/space/{id}/select', - async handler(request: any) { + internalRouter.post( + { + path: '/api/spaces/v1/space/{id}/select', + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { const { savedObjects, legacyConfig } = getLegacyAPI(); const { SavedObjectsClient } = savedObjects; @@ -34,18 +40,17 @@ export function initInternalSpacesApi(deps: InternalRouteDeps) { SavedObjectsClient.errors ); if (!existingSpace) { - return Boom.notFound(); + return response.notFound(); } - return { - location: addSpaceIdToPath(basePath, existingSpace.id, defaultRoute), - }; + return response.ok({ + body: { + location: addSpaceIdToPath(basePath, existingSpace.id, defaultRoute), + }, + }); } catch (error) { - return wrapError(error); + return response.customError(wrapError(error)); } - }, - options: { - pre: [routePreCheckLicenseFn], - }, - }); + } + ); } diff --git a/x-pack/legacy/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 similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts rename to x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts diff --git a/x-pack/legacy/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 similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts rename to x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/get_space_by_id.ts b/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/get_space_by_id.ts rename to x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts diff --git a/x-pack/legacy/plugins/spaces/server/routes/lib/index.ts b/x-pack/plugins/spaces/server/routes/lib/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/routes/lib/index.ts rename to x-pack/plugins/spaces/server/routes/lib/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/index.ts b/x-pack/plugins/spaces/server/spaces_service/index.ts similarity index 100% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/index.ts rename to x-pack/plugins/spaces/server/spaces_service/index.ts diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts similarity index 76% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts index 309ad508517c0..8590bd33db528 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.mock.ts @@ -5,12 +5,12 @@ */ import { SpacesServiceSetup } from './spaces_service'; -import { spacesClientMock } from '../../lib/spaces_client/spaces_client.mock'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { namespaceToSpaceId, spaceIdToNamespace } from '../../lib/utils/namespace'; +import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { namespaceToSpaceId, spaceIdToNamespace } from '../lib/utils/namespace'; const createSetupContractMock = (spaceId = DEFAULT_SPACE_ID) => { - const setupContract: SpacesServiceSetup = { + const setupContract: jest.Mocked = { getSpaceId: jest.fn().mockReturnValue(spaceId), isInDefaultSpace: jest.fn().mockReturnValue(spaceId === DEFAULT_SPACE_ID), getBasePath: jest.fn().mockReturnValue(''), diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts similarity index 93% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts index 9b8bf4826fdd0..27be68ae31e22 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.test.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.test.ts @@ -6,12 +6,12 @@ import * as Rx from 'rxjs'; import { SpacesService } from './spaces_service'; import { coreMock, elasticsearchServiceMock } from 'src/core/server/mocks'; -import { SpacesAuditLogger } from '../../lib/audit_logger'; +import { SpacesAuditLogger } from '../lib/audit_logger'; import { KibanaRequest, SavedObjectsService } from 'src/core/server'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { getSpaceIdFromPath } from '../../lib/spaces_url_parser'; -import { createOptionalPlugin } from '../../../../../server/lib/optional_plugin'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { getSpaceIdFromPath } from '../lib/spaces_url_parser'; import { LegacyAPI } from '../plugin'; +import { createOptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; const mockLogger = { trace: jest.fn(), @@ -49,7 +49,7 @@ const createService = async (serverBasePath: string = '') => { http: httpSetup, elasticsearch: elasticsearchServiceMock.createSetupContract(), config$: Rx.of({ maxSpaces: 10 }), - security: createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), + getSecurity: () => createOptionalPlugin({ get: () => null }, 'xpack.security', {}, 'security'), getSpacesAuditLogger: () => new SpacesAuditLogger({}), }); diff --git a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts similarity index 85% rename from x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts rename to x-pack/plugins/spaces/server/spaces_service/spaces_service.ts index 09231b46980de..5d935ffda686d 100644 --- a/x-pack/legacy/plugins/spaces/server/new_platform/spaces_service/spaces_service.ts +++ b/x-pack/plugins/spaces/server/spaces_service/spaces_service.ts @@ -8,14 +8,14 @@ import { map, take } from 'rxjs/operators'; import { Observable, Subscription, combineLatest } from 'rxjs'; import { Legacy } from 'kibana'; import { Logger, KibanaRequest, CoreSetup } from 'src/core/server'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; -import { DEFAULT_SPACE_ID } from '../../../common/constants'; -import { SecurityPlugin } from '../../../../security'; -import { SpacesClient } from '../../lib/spaces_client'; -import { getSpaceIdFromPath, addSpaceIdToPath } from '../../lib/spaces_url_parser'; -import { SpacesConfigType } from '../config'; -import { namespaceToSpaceId, spaceIdToNamespace } from '../../lib/utils/namespace'; +import { SecurityPlugin } from '../../../../legacy/plugins/security'; +import { OptionalPlugin } from '../../../../legacy/server/lib/optional_plugin'; import { LegacyAPI } from '../plugin'; +import { SpacesClient } from '../lib/spaces_client'; +import { ConfigType } from '../config'; +import { getSpaceIdFromPath, addSpaceIdToPath } from '../lib/spaces_url_parser'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; +import { spaceIdToNamespace, namespaceToSpaceId } from '../lib/utils/namespace'; type RequestFacade = KibanaRequest | Legacy.Request; @@ -36,8 +36,8 @@ export interface SpacesServiceSetup { interface SpacesServiceDeps { http: CoreSetup['http']; elasticsearch: CoreSetup['elasticsearch']; - security: OptionalPlugin; - config$: Observable; + getSecurity: () => OptionalPlugin; + config$: Observable; getSpacesAuditLogger(): any; } @@ -49,7 +49,7 @@ export class SpacesService { public async setup({ http, elasticsearch, - security, + getSecurity, config$, getSpacesAuditLogger, }: SpacesServiceDeps): Promise { @@ -97,6 +97,8 @@ export class SpacesService { ['space'] ); + const security = getSecurity(); + const authorization = security.isEnabled ? security.authorization : null; return new SpacesClient( diff --git a/x-pack/test/api_integration/apis/spaces/get_spaces.ts b/x-pack/test/api_integration/apis/spaces/get_spaces.ts new file mode 100644 index 0000000000000..0c40ff5fbf0fd --- /dev/null +++ b/x-pack/test/api_integration/apis/spaces/get_spaces.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. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const spaces = getService('spaces'); + + before(async () => { + await esArchiver.load('empty_kibana'); + await spaces.create({ + id: 'test-space', + name: 'Test Space', + }); + }); + + describe('space attributes', () => { + it('should allow a space to be created with a mixed-case hex color code', async () => { + await supertest + .post('/api/spaces/space') + .set('kbn-xsrf', 'xxx') + .send({ + id: 'api-test-space', + name: 'api test space', + disabledFeatures: [], + color: '#aaBB78', + }) + .expect(200, { + id: 'api-test-space', + name: 'api test space', + disabledFeatures: [], + color: '#aaBB78', + }); + }); + + it('should allow a space to be created with an avatar image', async () => { + await supertest + .post('/api/spaces/space') + .set('kbn-xsrf', 'xxx') + .send({ + id: 'api-test-space2', + name: 'Space with image', + disabledFeatures: [], + color: '#cafeba', + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==', + }) + .expect(200, { + id: 'api-test-space2', + name: 'Space with image', + disabledFeatures: [], + color: '#cafeba', + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAZdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjAuMTnU1rJkAAAB3klEQVRYR+2WzUrDQBCARzwqehE8ir1WPfgqRRA1bePBXgpe/MGCB9/Aiw+j+ASCB6kotklaEwW1F0WwNSaps9lV69awGzBpDzt8pJP9mXxsmk3ABH2oUEIilJAIJSRCCYlQQiKUkIh4QgY5agZodVjBowFrBktWQzDBU2ykiYaDuQpCYgnl3QunGzM6Z6YF+b5SkcgK1UH/aLbYReQiYL9d9/o+XFop5IU0Vl4uapAzoXC3eEBPw9vH1/wT6Vs2otPSkoH/IZzlzO/TU2vgQm8nl69Hp0H7nZ4OXogLJSSKBIUC3w88n+Ueyfv56fVZnqCQNVnCHbLrkV0Gd2d+GNkglsk438dhaTxloZDutV4wb06Vf40JcWZ2sMttPpE8NaHGeBnzIAhwPXqHseVB11EyLD0hxLUeaYud2a3B0g3k7GyFtrhX7F2RqhC+yV3jgTb2Rqdqf7/kUxYiWBOlTtXxfPJEtc8b5thGb+8AhL4ohnCNqQjZ2T2+K5rnw2M6KwEhKNDSGM3pTdxjhDgLbHkw/v/zw4AiPuSsfMzAiTidKxiF/ArpFqyzK8SMOlkwvloUMYRCtNvZLWeuIomd2Za/WZS4QomjhEQoIRFKSIQSEqGERAyfEH4YDBFQ/ARU6BiBxCAIQQAAAABJRU5ErkJggg==', + }); + }); + + it('creating a space with an invalid image fails', async () => { + await supertest + .post('/api/spaces/space') + .set('kbn-xsrf', 'xxx') + .send({ + id: 'api-test-space3', + name: 'Space with invalid image', + disabledFeatures: [], + color: '#cafeba', + imageUrl: 'invalidImage', + }) + .expect(400, { + error: 'Bad Request', + message: + 'child "imageUrl" fails because ["imageUrl" with value "invalidImage" fails to match the Image URL should start with \'data:image\' pattern]', + statusCode: 400, + validation: { + keys: ['imageUrl'], + source: 'payload', + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 61f1ab2190a09..85e877912ab6c 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -7,8 +7,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { EsArchiver } from 'src/es_archiver'; -import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; -import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 5d32d9f8adcf2..20b4d024803d7 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -9,7 +9,7 @@ import { SuperTest } from 'supertest'; import { EsArchiver } from 'src/es_archiver'; import { SavedObject } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../../../legacy/plugins/spaces/common/constants'; -import { CopyResponse } from '../../../../legacy/plugins/spaces/server/lib/copy_to_spaces'; +import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; import { DescribeFn, TestDefinitionAuthentication } from '../lib/types';