diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts new file mode 100644 index 0000000000000..8f316d12209ba --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.test.ts @@ -0,0 +1,414 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { capitalize } from 'lodash'; +import * as Rx from 'rxjs'; + +import type { SavedObjectsNamespaceType, SavedObjectsType } from '@kbn/core/server'; +import { kibanaResponseFactory } from '@kbn/core/server'; +import { + coreMock, + httpServerMock, + httpServiceMock, + savedObjectsClientMock, + savedObjectsTypeRegistryMock, +} from '@kbn/core/server/mocks'; + +import type { SpaceContentTypeSummaryItem } from './get_content_summary'; +import { initGetSpaceContentSummaryApi } from './get_content_summary'; +import { spacesConfig } from '../../../lib/__fixtures__'; +import { SpacesClientService } from '../../../spaces_client'; +import { SpacesService } from '../../../spaces_service'; +import { + createMockSavedObjectsRepository, + createSpaces, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; + +interface SetupParams { + importableAndExportableTypesMock: SavedObjectsType[]; +} + +describe('GET /internal/spaces/{spaceId}/content_summary', () => { + const spacesSavedObjects = createSpaces(); + + const setup = async (params?: SetupParams) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpServiceMock.createRouter(); + + const coreStart = coreMock.createStart(); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const savedObjectsClient = savedObjectsClientMock.create(); + const typeRegistry = savedObjectsTypeRegistryMock.create(); + + typeRegistry.getImportableAndExportableTypes.mockReturnValue( + params?.importableAndExportableTypesMock ?? [ + // don't need to include all types, just need a positive case (agnostic) and a negative case (non-agnostic) + { + name: 'dashboard', + namespaceType: 'multiple', + hidden: false, + mappings: { properties: {} }, + }, + { + name: 'globaltype', + namespaceType: 'agnostic', + hidden: false, + mappings: { properties: {} }, + }, + ] + ); + typeRegistry.isNamespaceAgnostic.mockImplementation((type: string) => + typeRegistry + .getImportableAndExportableTypes() + .some((t) => t.name === type && t.namespaceType === 'agnostic') + ); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, + }); + + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); + + const routeContext = { + ...mockRouteContext, + core: { + savedObjects: { + getClient: () => savedObjectsClient, + typeRegistry, + }, + }, + }; + + initGetSpaceContentSummaryApi({ + router, + getSpacesService: () => spacesServiceStart, + }); + + const [[config, routeHandler]] = router.get.mock.calls; + + return { + config, + routeHandler, + savedObjectsClient, + typeRegistry, + routeContext, + }; + }; + + it('correctly defines route.', async () => { + const { config } = await setup(); + + const paramsSchema = (config.validate as any).params; + + expect(config.options).toEqual({ tags: ['access:manageSpaces'] }); + expect(() => paramsSchema.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[spaceId]: expected value of type [string] but got [undefined]"` + ); + expect(() => paramsSchema.validate({ spaceId: '' })).toThrowErrorMatchingInlineSnapshot( + `"[spaceId]: value has length [0] but it must have a minimum length of [1]."` + ); + expect(() => paramsSchema.validate({ spaceId: '*' })).toThrowErrorMatchingInlineSnapshot( + `"[spaceId]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed."` + ); + }); + + it('returns http/403 when the license is invalid.', async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it('returns http/404 when retrieving a non-existent space.', async () => { + const { routeHandler, routeContext } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + params: { + spaceId: 'not-a-space', + }, + method: 'get', + }); + + const response = await routeHandler(routeContext, request, kibanaResponseFactory); + + expect(response.status).toEqual(404); + }); + + it('returns http/200 with non agnostic namespace types.', async () => { + const importableAndExportableTypesMock = [ + { + name: 'dashboard', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + management: { + displayName: 'dashboardDisplayName', + icon: 'dashboardIcon', + }, + mappings: { properties: {} }, + }, + { + name: 'query', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + mappings: { properties: {} }, + }, + { + name: 'globaltype', + namespaceType: 'agnostic' as SavedObjectsNamespaceType, + hidden: false, + mappings: { properties: {} }, + }, + ]; + const { routeHandler, routeContext, savedObjectsClient } = await setup({ + importableAndExportableTypesMock, + }); + + const request = httpServerMock.createKibanaRequest({ + params: { + spaceId: 'a-space', + }, + method: 'get', + }); + + const mockAggregationResult = { + total: 6, + aggregations: { + typesAggregation: { + buckets: [ + { + key: 'dashboard', + doc_count: 5, + }, + { + key: 'query', + doc_count: 1, + }, + ], + }, + }, + }; + + const findMock = savedObjectsClient.find as jest.Mock; + + findMock.mockReturnValue(mockAggregationResult); + + const response = await routeHandler(routeContext, request, kibanaResponseFactory); + + expect(findMock).toBeCalledWith({ + type: ['dashboard', 'query'], + namespaces: ['a-space'], + perPage: 0, + aggs: { + typesAggregation: { + terms: { + field: 'type', + size: 2, + }, + }, + }, + }); + + expect(response.status).toEqual(200); + expect(response.payload?.summary).toHaveLength(2); + }); + + it('returns http/200 with correct meta information.', async () => { + const importableAndExportableTypesMock = [ + { + name: 'dashboard', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + management: { + displayName: 'dashboardDisplayName', + icon: 'dashboardIcon', + }, + mappings: { properties: {} }, + }, + { + name: 'query', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + mappings: { properties: {} }, + }, + ]; + const { routeHandler, routeContext, savedObjectsClient } = await setup({ + importableAndExportableTypesMock, + }); + + const request = httpServerMock.createKibanaRequest({ + params: { + spaceId: 'a-space', + }, + method: 'get', + }); + + const mockAggregationResult = { + total: 10, + aggregations: { + typesAggregation: { + buckets: [ + { + key: 'dashboard', + doc_count: 5, + }, + { + key: 'query', + doc_count: 5, + }, + ], + }, + }, + }; + + const findMock = savedObjectsClient.find as jest.Mock; + + findMock.mockReturnValue(mockAggregationResult); + + const response = await routeHandler(routeContext, request, kibanaResponseFactory); + + expect(findMock).toBeCalledWith({ + type: ['dashboard', 'query'], + namespaces: ['a-space'], + perPage: 0, + aggs: { + typesAggregation: { + terms: { + field: 'type', + size: 2, + }, + }, + }, + }); + + expect(response.status).toEqual(200); + expect(response.payload!.summary).toHaveLength(2); + + const [dashboardType, queryType] = importableAndExportableTypesMock; + const [dashboardTypeSummary, queryTypeSummary] = response.payload!.summary; + + expect(dashboardTypeSummary.displayName).toEqual(dashboardType.management?.displayName); + expect(dashboardTypeSummary.icon).toEqual(dashboardType.management?.icon); + + expect(queryTypeSummary.displayName).toEqual(capitalize(queryType.name)); + expect(queryTypeSummary.icon).toBe(undefined); + }); + + it('returns http/200 with data sorted by displayName.', async () => { + const importableAndExportableTypesMock = [ + { + name: 'dashboard', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + management: { + displayName: 'Test Display Dashboard Name', + }, + mappings: { properties: {} }, + }, + { + name: 'query', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + management: { + displayName: 'My Display Name', + }, + mappings: { properties: {} }, + }, + { + name: 'search', + namespaceType: 'multiple' as SavedObjectsNamespaceType, + hidden: false, + mappings: { properties: {} }, + }, + ]; + const { routeHandler, routeContext, savedObjectsClient } = await setup({ + importableAndExportableTypesMock, + }); + + const request = httpServerMock.createKibanaRequest({ + params: { + spaceId: 'a-space', + }, + method: 'get', + }); + + const mockAggregationResult = { + total: 15, + aggregations: { + typesAggregation: { + buckets: [ + { + key: 'dashboard', + doc_count: 5, + }, + { + key: 'query', + doc_count: 5, + }, + { + key: 'search', + doc_count: 5, + }, + ], + }, + }, + }; + + const findMock = savedObjectsClient.find as jest.Mock; + + findMock.mockReturnValue(mockAggregationResult); + + const response = await routeHandler(routeContext, request, kibanaResponseFactory); + + expect(findMock).toBeCalledWith({ + type: ['dashboard', 'query', 'search'], + namespaces: ['a-space'], + perPage: 0, + aggs: { + typesAggregation: { + terms: { + field: 'type', + size: 3, + }, + }, + }, + }); + + expect(response.status).toEqual(200); + expect(response.payload!.summary).toHaveLength(3); + + const types = response.payload!.summary.map((item: SpaceContentTypeSummaryItem) => item.type); + + expect(types).toEqual(['query', 'search', 'dashboard']); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts new file mode 100644 index 0000000000000..b582c304fd13b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/internal/get_content_summary.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { capitalize, sortBy } from 'lodash'; + +import { schema } from '@kbn/config-schema'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; + +import type { InternalRouteDeps } from '.'; +import { wrapError } from '../../../lib/errors'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +interface SpaceContentTypeMetaInfo { + displayName: string; + icon?: string; +} + +interface TypesAggregation { + typesAggregation: { + buckets: Array<{ doc_count: number; key: string }>; + }; +} + +type SpaceContentTypesMetaData = Record; + +export interface SpaceContentTypeSummaryItem extends SpaceContentTypeMetaInfo { + count: number; + type: string; +} + +export function initGetSpaceContentSummaryApi(deps: InternalRouteDeps) { + const { router, getSpacesService } = deps; + + router.get( + { + path: '/internal/spaces/{spaceId}/content_summary', + options: { + tags: ['access:manageSpaces'], + }, + validate: { + params: schema.object({ + spaceId: schema.string({ + validate: (value) => { + if (!SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed.`; + } + }, + minLength: 1, + }), + }), + }, + }, + createLicensedRouteHandler(async (context, request, response) => { + try { + const spaceId = request.params.spaceId; + const spacesClient = getSpacesService().createSpacesClient(request); + + await spacesClient.get(spaceId); + + const { getClient, typeRegistry } = (await context.core).savedObjects; + const client = getClient(); + + const types = typeRegistry + .getImportableAndExportableTypes() + .filter((type) => !typeRegistry.isNamespaceAgnostic(type.name)); + + const searchTypeNames = types.map((type) => type.name); + + const data = await client.find({ + type: searchTypeNames, + perPage: 0, + namespaces: [spaceId], + aggs: { + typesAggregation: { + terms: { + field: 'type', + size: types.length, + }, + }, + }, + }); + + const typesMetaInfo = types.reduce((acc, currentType) => { + acc[currentType.name] = { + displayName: currentType.management?.displayName ?? capitalize(currentType.name), + icon: currentType.management?.icon, + }; + + return acc; + }, {}); + + const summary = sortBy( + data.aggregations?.typesAggregation.buckets.map((item) => ({ + count: item.doc_count, + type: item.key, + ...typesMetaInfo[item.key], + })), + (item) => item.displayName.toLowerCase() + ); + + return response.ok({ body: { summary, total: data.total } }); + } catch (error) { + if (SavedObjectsErrorHelpers.isNotFoundError(error)) { + return response.notFound(); + } + + return response.customError(wrapError(error)); + } + }) + ); +} diff --git a/x-pack/plugins/spaces/server/routes/api/internal/index.ts b/x-pack/plugins/spaces/server/routes/api/internal/index.ts index 0cf8135d7b718..5e2120d896b87 100644 --- a/x-pack/plugins/spaces/server/routes/api/internal/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/internal/index.ts @@ -6,6 +6,7 @@ */ import { initGetActiveSpaceApi } from './get_active_space'; +import { initGetSpaceContentSummaryApi } from './get_content_summary'; import type { SpacesServiceStart } from '../../../spaces_service/spaces_service'; import type { SpacesRouter } from '../../../types'; @@ -16,4 +17,5 @@ export interface InternalRouteDeps { export function initInternalSpacesApi(deps: InternalRouteDeps) { initGetActiveSpaceApi(deps); + initGetSpaceContentSummaryApi(deps); } diff --git a/x-pack/test/api_integration/apis/spaces/get_content_summary.ts b/x-pack/test/api_integration/apis/spaces/get_content_summary.ts new file mode 100644 index 0000000000000..39fa00ebd8ff1 --- /dev/null +++ b/x-pack/test/api_integration/apis/spaces/get_content_summary.ts @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const sampleDashboard = { + contentTypeId: 'dashboard', + data: { + kibanaSavedObjectMeta: {}, + title: 'Sample dashboard', + }, + options: { + references: [], + overwrite: true, + }, + version: 2, +}; + +const sampleIndexPattern = { + contentTypeId: 'index-pattern', + data: { + fieldAttrs: '{}', + title: 'index-pattern-1', + timeFieldName: '@timestamp', + sourceFilters: '[]', + fields: '[]', + fieldFormatMap: '{}', + typeMeta: '{}', + runtimeFieldMap: '{}', + name: 'index-pattern-1', + }, + options: { id: 'index-pattern-1' }, + version: 1, +}; + +const ATestSpace = 'ab-space'; +const BTestSpace = 'ac-space'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const spacesService = getService('spaces'); + + describe('GET /internal/spaces/{spaceId}/content_summary', () => { + before(async () => { + await spacesService.create({ + id: ATestSpace, + name: 'AB Space', + disabledFeatures: [], + color: '#AABBCC', + }); + + await spacesService.create({ + id: BTestSpace, + name: 'AC Space', + disabledFeatures: [], + color: '#AABBCC', + }); + }); + + after(async () => { + await spacesService.delete('ab-space'); + await spacesService.delete('ac-space'); + }); + + it(`returns content summary for ${ATestSpace} space`, async () => { + await supertest + .post(`/s/${ATestSpace}/api/content_management/rpc/create`) + .set('kbn-xsrf', 'xxx') + .send(sampleDashboard); + + await supertest + .post(`/s/${ATestSpace}/api/content_management/rpc/create`) + .set('kbn-xsrf', 'xxx') + .send(sampleDashboard); + + await supertest + .get(`/internal/spaces/${ATestSpace}/content_summary`) + .set('kbn-xsrf', 'xxx') + .expect(200) + .then((response) => { + const { summary, total } = response.body; + expect(summary).to.eql([ + { + count: 2, + type: 'dashboard', + displayName: 'Dashboard', + icon: 'dashboardApp', + }, + ]); + expect(total).to.eql(2); + }); + }); + + it(`returns content summary for ${BTestSpace} space`, async () => { + await supertest + .post(`/s/${BTestSpace}/api/content_management/rpc/create`) + .set('kbn-xsrf', 'xxx') + .send(sampleDashboard); + + await supertest + .post(`/s/${BTestSpace}/api/content_management/rpc/create`) + .set('kbn-xsrf', 'foo') + .set('x-elastic-internal-origin', 'foo') + .send(sampleIndexPattern); + + await supertest + .get(`/internal/spaces/${BTestSpace}/content_summary`) + .set('kbn-xsrf', 'xxx') + .expect(200) + .then((response) => { + const { summary, total } = response.body; + expect(summary).to.eql([ + { + count: 1, + type: 'dashboard', + displayName: 'Dashboard', + icon: 'dashboardApp', + }, + { + count: 1, + displayName: 'data view', + icon: 'indexPatternApp', + type: 'index-pattern', + }, + ]); + + expect(total).to.eql(2); + }); + }); + + it('returns 404 when the space is not found', async () => { + await supertest + .get('/internal/spaces/not-found-space/content_summary') + .set('kbn-xsrf', 'xxx') + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Not Found', + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/spaces/index.ts b/x-pack/test/api_integration/apis/spaces/index.ts index 1ec1faac535ce..6fd7a11458c56 100644 --- a/x-pack/test/api_integration/apis/spaces/index.ts +++ b/x-pack/test/api_integration/apis/spaces/index.ts @@ -12,5 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./get_active_space')); loadTestFile(require.resolve('./saved_objects')); loadTestFile(require.resolve('./space_attributes')); + loadTestFile(require.resolve('./get_content_summary')); }); }