From b70535f9216cce787e3c24c8671185ec4a32e005 Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Fri, 22 Nov 2019 16:55:50 -0600 Subject: [PATCH] Allow chromeless applications to render via non-/app routes --- .../kibana-plugin-public.app.appbasepath.md | 13 + .../core/public/kibana-plugin-public.app.md | 1 + package.json | 1 + .../application/application_service.mock.ts | 5 +- .../application/application_service.test.ts | 369 ++++++++++++++++++ .../application/application_service.test.tsx | 235 ----------- .../application/application_service.tsx | 189 +++++---- .../capabilities/capabilities_service.mock.ts | 17 +- .../capabilities/capabilities_service.test.ts | 25 +- .../capabilities/capabilities_service.tsx | 31 +- .../application_service.test.tsx | 103 +++++ .../integration_tests/router.test.tsx | 116 +++--- .../application/integration_tests/utils.tsx | 74 ++++ src/core/public/application/test_types.ts | 37 ++ src/core/public/application/types.ts | 25 ++ .../public/application/ui/app_container.tsx | 141 +++---- src/core/public/application/ui/app_router.tsx | 62 +-- src/core/public/core_system.ts | 4 +- src/core/public/public.api.md | 1 + .../application_context_provider.mock.ts | 31 ++ ...application_context_provider.test.mocks.ts | 26 ++ .../application_context_provider.test.ts | 111 ++++++ .../application_context_provider.ts | 175 +++++++++ .../application/application_service.mock.ts | 39 ++ .../application/application_service.test.ts | 56 +++ .../server/application/application_service.ts | 54 +++ .../server/application/capabilities/index.ts | 21 + .../capabilities/merge_capabilities.test.ts | 86 ++++ .../capabilities/merge_capabilities.ts | 45 +++ .../server/application/capabilities/types.ts | 27 ++ src/core/server/application/index.ts | 22 ++ .../application/merge_variables.test.ts | 199 ++++++++++ .../server/application/merge_variables.ts | 39 ++ src/core/server/application/types.ts | 39 ++ src/core/server/application/views/chrome.pug | 305 +++++++++++++++ src/core/server/application/views/ui_app.pug | 140 +++++++ src/core/server/config/config.mock.ts | 34 ++ src/core/server/csp/index.test.ts | 62 +++ src/core/server/csp/index.ts | 31 ++ src/core/server/http/http_config.ts | 19 +- src/core/server/http/http_server.ts | 2 + src/core/server/http/http_service.mock.ts | 22 +- src/core/server/http/types.ts | 9 + src/core/server/index.ts | 9 + src/core/server/legacy/index.ts | 7 +- src/core/server/legacy/legacy_service.mock.ts | 40 ++ src/core/server/legacy/legacy_service.ts | 1 + src/core/server/mocks.ts | 1 + .../server/plugins/plugins_service.mock.ts | 40 +- src/core/server/server.test.mocks.ts | 10 +- src/core/server/server.ts | 27 +- .../ui_settings/ui_settings_service.mock.ts | 1 + .../capabilities/capabilities_mixin.test.ts | 26 ++ .../server/capabilities/capabilities_mixin.ts | 18 +- .../server/capabilities/capabilities_route.ts | 4 +- src/legacy/server/capabilities/index.ts | 2 +- .../capabilities/resolve_capabilities.ts | 6 +- src/legacy/server/kbn_server.d.ts | 13 +- x-pack/plugins/security/server/plugin.ts | 4 +- x-pack/plugins/spaces/server/plugin.ts | 5 +- yarn.lock | 7 +- 61 files changed, 2615 insertions(+), 649 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-public.app.appbasepath.md create mode 100644 src/core/public/application/application_service.test.ts delete mode 100644 src/core/public/application/application_service.test.tsx create mode 100644 src/core/public/application/integration_tests/application_service.test.tsx create mode 100644 src/core/public/application/integration_tests/utils.tsx create mode 100644 src/core/public/application/test_types.ts create mode 100644 src/core/server/application/application_context_provider.mock.ts create mode 100644 src/core/server/application/application_context_provider.test.mocks.ts create mode 100644 src/core/server/application/application_context_provider.test.ts create mode 100644 src/core/server/application/application_context_provider.ts create mode 100644 src/core/server/application/application_service.mock.ts create mode 100644 src/core/server/application/application_service.test.ts create mode 100644 src/core/server/application/application_service.ts create mode 100644 src/core/server/application/capabilities/index.ts create mode 100644 src/core/server/application/capabilities/merge_capabilities.test.ts create mode 100644 src/core/server/application/capabilities/merge_capabilities.ts create mode 100644 src/core/server/application/capabilities/types.ts create mode 100644 src/core/server/application/index.ts create mode 100644 src/core/server/application/merge_variables.test.ts create mode 100644 src/core/server/application/merge_variables.ts create mode 100644 src/core/server/application/types.ts create mode 100644 src/core/server/application/views/chrome.pug create mode 100644 src/core/server/application/views/ui_app.pug create mode 100644 src/core/server/config/config.mock.ts create mode 100644 src/core/server/csp/index.test.ts create mode 100644 src/core/server/csp/index.ts create mode 100644 src/core/server/legacy/legacy_service.mock.ts diff --git a/docs/development/core/public/kibana-plugin-public.app.appbasepath.md b/docs/development/core/public/kibana-plugin-public.app.appbasepath.md new file mode 100644 index 0000000000000..eec2b027cbd27 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-public.app.appbasepath.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-public](./kibana-plugin-public.md) > [App](./kibana-plugin-public.app.md) > [appBasePath](./kibana-plugin-public.app.appbasepath.md) + +## App.appBasePath property + +Override the application's base routing path from `/app/${id}`. Must be unique across registered applications. + +Signature: + +```typescript +appBasePath?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-public.app.md b/docs/development/core/public/kibana-plugin-public.app.md index c500c080a5feb..723be01a5d9c6 100644 --- a/docs/development/core/public/kibana-plugin-public.app.md +++ b/docs/development/core/public/kibana-plugin-public.app.md @@ -16,6 +16,7 @@ export interface App extends AppBase | Property | Type | Description | | --- | --- | --- | +| [appBasePath](./kibana-plugin-public.app.appbasepath.md) | string | Override the application's base routing path from /app/${id}. Must be unique across registered applications. | | [chromeless](./kibana-plugin-public.app.chromeless.md) | boolean | Hide the UI chrome when the application is mounted. Defaults to false. Takes precedence over chrome service visibility settings. | | [mount](./kibana-plugin-public.app.mount.md) | (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise<AppUnmount> | A mount function called when the user navigates to this app's route. | diff --git a/package.json b/package.json index 04415b481d5dd..074339e53d98c 100644 --- a/package.json +++ b/package.json @@ -327,6 +327,7 @@ "@types/pngjs": "^3.3.2", "@types/podium": "^1.0.0", "@types/prop-types": "^15.5.3", + "@types/pug": "^2.0.4", "@types/reach__router": "^1.2.6", "@types/react": "^16.8.0", "@types/react-dom": "^16.8.0", diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index a2db755224636..483153389cb4c 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -20,15 +20,13 @@ import { Subject } from 'rxjs'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; -import { ApplicationService } from './application_service'; import { ApplicationSetup, InternalApplicationStart, ApplicationStart, InternalApplicationSetup, } from './types'; - -type ApplicationServiceContract = PublicMethodsOf; +import { ApplicationServiceContract } from './test_types'; const createSetupContractMock = (): jest.Mocked => ({ register: jest.fn(), @@ -69,7 +67,6 @@ export const applicationServiceMock = { create: createMock, createSetupContract: createSetupContractMock, createStartContract: createStartContractMock, - createInternalSetupContract: createInternalSetupContractMock, createInternalStartContract: createInternalStartContractMock, }; diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts new file mode 100644 index 0000000000000..b8b51e15f5b89 --- /dev/null +++ b/src/core/public/application/application_service.test.ts @@ -0,0 +1,369 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createElement } from 'react'; +import { shallow } from 'enzyme'; + +import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; +import { contextServiceMock } from '../context/context_service.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; +import { MockLifecycle } from './test_types'; +import { ApplicationService } from './application_service'; + +function mount() {} + +describe('#setup()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + setupDeps = { + context: contextServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract({ basePath: '/test' }), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + describe('register', () => { + it('throws an error if two apps with the same id are registered', () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the id \\"app1\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { register } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => + register(Symbol(), { id: 'app1', mount } as any) + ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); + }); + + it('throws an error if an App with the same appBasePath is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appBasePath: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appBasePath \\"/app/app1\\""` + ); + expect(() => registerLegacyApp({ id: 'app1' } as any)).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appBasePath \\"/app/app1\\""` + ); + }); + }); + + describe('registerLegacyApp', () => { + it('throws an error if two apps with the same id are registered', () => { + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app2' } as any); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"A legacy application is already registered with the id \\"app2\\""` + ); + }); + + it('throws error if additional apps are registered after setup', async () => { + const { registerLegacyApp } = service.setup(setupDeps); + + await service.start(startDeps); + expect(() => registerLegacyApp({ id: 'app2' } as any)).toThrowErrorMatchingInlineSnapshot( + `"Applications cannot be registered after \\"setup\\""` + ); + }); + + it('throws an error if a LegacyApp with the same appBasePath is registered', () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'app1' } as any); + + expect(() => + register(Symbol(), { id: 'app2', mount, appBasePath: '/app/app1' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appBasePath \\"/app/app1\\""` + ); + expect(() => + registerLegacyApp({ id: 'app1:other' } as any) + ).toThrowErrorMatchingInlineSnapshot( + `"An application is already registered with the appBasePath \\"/app/app1\\""` + ); + }); + }); + + it("`registerMountContext` calls context container's registerContext", () => { + const { registerMountContext } = service.setup(setupDeps); + const container = setupDeps.context.createContextContainer.mock.results[0].value; + const pluginId = Symbol(); + + registerMountContext(pluginId, 'test' as any, mount as any); + expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', mount); + }); +}); + +describe('#start()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + MockHistory.push.mockReset(); + setupDeps = { + context: contextServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract({ basePath: '/test' }), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + it('rejects if called prior to #setup()', async () => { + await expect(service.start(startDeps)).rejects.toThrowErrorMatchingInlineSnapshot( + `"ApplicationService#setup() must be invoked before start."` + ); + }); + + it('exposes available apps', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'app2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appBasePath": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "app2" => Object { + "id": "app2", + }, + } + `); + }); + + it('passes metadata to capabilities', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + await service.start(startDeps); + + expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ + injectedMetadata: startDeps.injectedMetadata, + }); + }); + + it('filters available applications based on capabilities', async () => { + MockCapabilitiesService.start.mockResolvedValueOnce({ + capabilities: { + navLinks: { + app1: true, + app2: false, + legacyApp1: true, + legacyApp2: false, + }, + }, + } as any); + + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount } as any); + registerLegacyApp({ id: 'legacyApp2' } as any); + + const { availableApps, availableLegacyApps } = await service.start(startDeps); + + expect(availableApps).toMatchInlineSnapshot(` + Map { + "app1" => Object { + "appBasePath": "/app/app1", + "id": "app1", + "mount": [Function], + }, + } + `); + expect(availableLegacyApps).toMatchInlineSnapshot(` + Map { + "legacyApp1" => Object { + "id": "legacyApp1", + }, + } + `); + }); + + describe('getComponent', () => { + it('returns renderable JSX tree', async () => { + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toMatchInlineSnapshot(` + + `); + }); + + it('renders null when in legacy mode', async () => { + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { getComponent } = await service.start(startDeps); + + expect(() => shallow(createElement(getComponent))).not.toThrow(); + expect(getComponent()).toBe(null); + }); + }); + + describe('getUrlForApp', () => { + it('creates URL for unregistered appId', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + }); + + it('creates URL for registered appId', async () => { + const { register, registerLegacyApp } = service.setup(setupDeps); + + register(Symbol(), { id: 'app1', mount } as any); + registerLegacyApp({ id: 'legacyApp1' } as any); + register(Symbol(), { id: 'app2', mount, appBasePath: '/custom/path' } as any); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1')).toBe('/app/app1'); + expect(getUrlForApp('legacyApp1')).toBe('/app/legacyApp1'); + expect(getUrlForApp('app2')).toBe('/custom/path'); + }); + + it('creates URLs with path parameter', async () => { + service.setup(setupDeps); + + const { getUrlForApp } = await service.start(startDeps); + + expect(getUrlForApp('app1', { path: 'deep/link' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '/deep//link/' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: '//deep/link//' })).toBe('/app/app1/deep/link'); + expect(getUrlForApp('app1', { path: 'deep/link///' })).toBe('/app/app1/deep/link'); + }); + }); + + describe('navigateToApp', () => { + it('changes the browser history to /app/:appId', async () => { + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('myOtherApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); + }); + + it('changes the browser history for custom appBasePaths', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appBasePath: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); + + navigateToApp('app2'); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', undefined); + }); + + it('appends a path if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appBasePath: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/app/myTestApp/deep/link/to/location/2', + undefined + ); + + navigateToApp('app2', { path: 'deep/link/to/location/2' }); + expect(MockHistory.push).toHaveBeenCalledWith( + '/custom/path/deep/link/to/location/2', + undefined + ); + }); + + it('includes state if specified', async () => { + const { register } = service.setup(setupDeps); + + register(Symbol(), { id: 'app2', mount, appBasePath: '/custom/path' } as any); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); + + navigateToApp('app2', { state: 'my-state' }); + expect(MockHistory.push).toHaveBeenCalledWith('/custom/path', 'my-state'); + }); + + it('redirects when in legacyMode', async () => { + setupDeps.redirectTo = jest.fn(); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + service.setup(setupDeps); + + const { navigateToApp } = await service.start(startDeps); + + navigateToApp('myTestApp'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/myTestApp'); + }); + }); +}); diff --git a/src/core/public/application/application_service.test.tsx b/src/core/public/application/application_service.test.tsx deleted file mode 100644 index 5b374218a5932..0000000000000 --- a/src/core/public/application/application_service.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; - -import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; -import { MockCapabilitiesService, MockHistory } from './application_service.test.mocks'; -import { ApplicationService } from './application_service'; -import { contextServiceMock } from '../context/context_service.mock'; -import { httpServiceMock } from '../http/http_service.mock'; - -describe('#setup()', () => { - describe('register', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1' } as any); - expect(() => - setup.register(Symbol(), { id: 'app1' } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"An application is already registered with the id \\"app1\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.register(Symbol(), { id: 'app1' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - }); - - describe('registerLegacyApp', () => { - it('throws an error if two apps with the same id are registered', () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'app2' } as any); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot( - `"A legacy application is already registered with the id \\"app2\\""` - ); - }); - - it('throws error if additional apps are registered after setup', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - expect(() => - setup.registerLegacyApp({ id: 'app2' } as any) - ).toThrowErrorMatchingInlineSnapshot(`"Applications cannot be registered after \\"setup\\""`); - }); - }); - - it("`registerMountContext` calls context container's registerContext", () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - const container = context.createContextContainer.mock.results[0].value; - const pluginId = Symbol(); - const noop = () => {}; - setup.registerMountContext(pluginId, 'test' as any, noop as any); - expect(container.registerContext).toHaveBeenCalledWith(pluginId, 'test', noop); - }); -}); - -describe('#start()', () => { - beforeEach(() => { - MockHistory.push.mockReset(); - }); - - it('exposes available apps from capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1' } as any); - setup.registerLegacyApp({ id: 'app2' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - const startContract = await service.start({ http, injectedMetadata }); - - expect(startContract.availableApps).toMatchInlineSnapshot(` - Map { - "app1" => Object { - "id": "app1", - }, - } - `); - expect(startContract.availableLegacyApps).toMatchInlineSnapshot(` - Map { - "app2" => Object { - "id": "app2", - }, - } - `); - }); - - it('passes registered applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.register(Symbol(), { id: 'app1' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map([['app1', { id: 'app1' }]]), - legacyApps: new Map(), - injectedMetadata, - }); - }); - - it('passes registered legacy applications to capabilities', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - const setup = service.setup({ context }); - setup.registerLegacyApp({ id: 'legacyApp1' } as any); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - await service.start({ http, injectedMetadata }); - - expect(MockCapabilitiesService.start).toHaveBeenCalledWith({ - apps: new Map(), - legacyApps: new Map([['legacyApp1', { id: 'legacyApp1' }]]), - injectedMetadata, - }); - }); - - it('returns renderable JSX tree', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - expect(() => shallow(React.createElement(() => start.getComponent()))).not.toThrow(); - }); - - describe('navigateToApp', () => { - it('changes the browser history to /app/:appId', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', undefined); - start.navigateToApp('myOtherApp'); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myOtherApp', undefined); - }); - - it('appends a path if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { path: 'deep/link/to/location/2' }); - expect(MockHistory.push).toHaveBeenCalledWith( - '/app/myTestApp/deep/link/to/location/2', - undefined - ); - }); - - it('includes state if specified', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(false); - const start = await service.start({ http, injectedMetadata }); - - start.navigateToApp('myTestApp', { state: 'my-state' }); - expect(MockHistory.push).toHaveBeenCalledWith('/app/myTestApp', 'my-state'); - }); - - it('redirects when in legacyMode', async () => { - const service = new ApplicationService(); - const context = contextServiceMock.createSetupContract(); - service.setup({ context }); - - const http = httpServiceMock.createStartContract(); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(true); - const redirectTo = jest.fn(); - const start = await service.start({ http, injectedMetadata, redirectTo }); - start.navigateToApp('myTestApp'); - expect(redirectTo).toHaveBeenCalledWith('/app/myTestApp'); - }); - }); -}); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 935844baddf86..8dd5be3a2d628 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -17,30 +17,29 @@ * under the License. */ -import { createBrowserHistory } from 'history'; -import { BehaviorSubject } from 'rxjs'; import React from 'react'; +import { BehaviorSubject } from 'rxjs'; +import { createBrowserHistory, History } from 'history'; -import { InjectedMetadataStart } from '../injected_metadata'; -import { CapabilitiesService } from './capabilities'; -import { AppRouter } from './ui'; -import { HttpStart } from '../http'; +import { InjectedMetadataSetup, InjectedMetadataStart } from '../injected_metadata'; +import { HttpSetup } from '../http'; import { ContextSetup, IContextContainer } from '../context'; +import { AppRouter } from './ui'; +import { CapabilitiesService, Capabilities } from './capabilities'; import { App, LegacyApp, AppMounter, + LegacyAppMounter, + Mounter, InternalApplicationSetup, InternalApplicationStart, } from './types'; interface SetupDeps { context: ContextSetup; -} - -interface StartDeps { - http: HttpStart; - injectedMetadata: InjectedMetadataStart; + http: HttpSetup; + injectedMetadata: InjectedMetadataSetup; /** * Only necessary for redirecting to legacy apps * @deprecated @@ -48,128 +47,124 @@ interface StartDeps { redirectTo?: (path: string) => void; } -interface AppBox { - app: App; - mount: AppMounter; +interface StartDeps { + injectedMetadata: InjectedMetadataStart; } +const filterAvailable = (map: Map, capabilities: Capabilities) => + new Map( + [...map].filter( + ([id]) => capabilities.navLinks[id] === undefined || capabilities.navLinks[id] === true + ) + ); +const hasAppBasePath = (mounters: Map, appBasePath?: string) => + [...mounters.values()].some(mounter => mounter.appBasePath === appBasePath); +const getAppUrl = (mounters: Map, appId: string, path: string = '') => + `/${mounters.get(appId)?.appBasePath ?? `/app/${appId}`}/${path}` + .replace(/\/{2,}/g, '/') // Remove duplicate slashes + .replace(/\/$/, ''); // Remove trailing slash + /** * Service that is responsible for registering new applications. * @internal */ export class ApplicationService { - private readonly apps$ = new BehaviorSubject>(new Map()); - private readonly legacyApps$ = new BehaviorSubject>(new Map()); + private readonly apps = new Map(); + private readonly legacyApps = new Map(); + private readonly mounters = new Map(); private readonly capabilities = new CapabilitiesService(); + private registrationClosed = false; + private history: History | null = null; private mountContext?: IContextContainer; + private currentAppId$?: BehaviorSubject; + private navigate?: (url: string, state: any) => void; + + public setup({ + context, + http: { basePath }, + injectedMetadata, + redirectTo = (path: string) => (window.location.href = path), + }: SetupDeps): InternalApplicationSetup { + // Only setup history if we're not in legacy mode + if (!injectedMetadata.getLegacyMode()) { + this.history = createBrowserHistory({ basename: basePath.get() }); + } - public setup({ context }: SetupDeps): InternalApplicationSetup { + // If we do not have history available, use redirectTo to do a full page refresh. + this.navigate = (url, state) => + this.history ? this.history.push(url, state) : redirectTo(basePath.prepend(url)); this.mountContext = context.createContextContainer(); return { - register: (plugin: symbol, app: App) => { - if (this.apps$.value.has(app.id)) { - throw new Error(`An application is already registered with the id "${app.id}"`); - } - if (this.apps$.isStopped) { + registerMountContext: this.mountContext!.registerContext, + register: (plugin, app) => { + app = { appBasePath: `/app/${app.id}`, ...app }; + + if (this.registrationClosed) { throw new Error(`Applications cannot be registered after "setup"`); + } else if (this.apps.has(app.id)) { + throw new Error(`An application is already registered with the id "${app.id}"`); + } else if (hasAppBasePath(this.mounters, app.appBasePath)) { + throw new Error( + `An application is already registered with the appBasePath "${app.appBasePath}"` + ); } - const appBox: AppBox = { - app, - mount: this.mountContext!.createHandler(plugin, app.mount), + const handler = this.mountContext!.createHandler(plugin, app.mount); + const mount: AppMounter = async params => { + const unmount = await handler(params); + this.currentAppId$!.next(app.id); + return unmount; }; - this.apps$.next(new Map([...this.apps$.value.entries(), [app.id, appBox]])); + this.apps.set(app.id, app); + this.mounters.set(app.id, { appBasePath: app.appBasePath!, mount }); }, - registerLegacyApp: (app: LegacyApp) => { - if (this.legacyApps$.value.has(app.id)) { - throw new Error(`A legacy application is already registered with the id "${app.id}"`); - } - if (this.legacyApps$.isStopped) { + registerLegacyApp: app => { + const appBasePath = `/app/${app.id.split(':')[0]}`; + + if (this.registrationClosed) { throw new Error(`Applications cannot be registered after "setup"`); + } else if (this.legacyApps.has(app.id)) { + throw new Error(`A legacy application is already registered with the id "${app.id}"`); + } else if (hasAppBasePath(this.mounters, appBasePath)) { + throw new Error( + `An application is already registered with the appBasePath "${appBasePath}"` + ); } - this.legacyApps$.next(new Map([...this.legacyApps$.value.entries(), [app.id, app]])); + const mount: LegacyAppMounter = () => redirectTo(basePath.prepend(appBasePath)); + this.legacyApps.set(app.id, app); + this.mounters.set(app.id, { appBasePath, mount, unmountBeforeMounting: true }); }, - registerMountContext: this.mountContext!.registerContext, }; } - public async start({ - http, - injectedMetadata, - redirectTo = (path: string) => (window.location.href = path), - }: StartDeps): Promise { + public async start({ injectedMetadata }: StartDeps): Promise { if (!this.mountContext) { - throw new Error(`ApplicationService#setup() must be invoked before start.`); + throw new Error('ApplicationService#setup() must be invoked before start.'); } - // Disable registration of new applications - this.apps$.complete(); - this.legacyApps$.complete(); - - const legacyMode = injectedMetadata.getLegacyMode(); - const currentAppId$ = new BehaviorSubject(undefined); - const { availableApps, availableLegacyApps, capabilities } = await this.capabilities.start({ - apps: new Map([...this.apps$.value].map(([id, { app }]) => [id, app])), - legacyApps: this.legacyApps$.value, - injectedMetadata, - }); - - // Only setup history if we're not in legacy mode - const history = legacyMode ? null : createBrowserHistory({ basename: http.basePath.get() }); + this.registrationClosed = true; + this.currentAppId$ = new BehaviorSubject(undefined); + const { capabilities } = await this.capabilities.start({ injectedMetadata }); + const availableMounters = filterAvailable(this.mounters, capabilities); return { - availableApps, - availableLegacyApps, + availableApps: filterAvailable(this.apps, capabilities), + availableLegacyApps: filterAvailable(this.legacyApps, capabilities), capabilities, + currentAppId$: this.currentAppId$, registerMountContext: this.mountContext.registerContext, - currentAppId$, - - getUrlForApp: (appId, options: { path?: string } = {}) => { - return http.basePath.prepend(appPath(appId, options)); - }, - + getUrlForApp: (appId, { path }: { path?: string } = {}) => + getAppUrl(availableMounters, appId, path), navigateToApp: (appId, { path, state }: { path?: string; state?: any } = {}) => { - if (legacyMode) { - // If we're in legacy mode, do a full page refresh to load the NP app. - redirectTo(http.basePath.prepend(appPath(appId, { path }))); - } else { - // basePath not needed here because `history` is configured with basename - history!.push(appPath(appId, { path }), state); - } - }, - - getComponent: () => { - if (legacyMode) { - return null; - } - - // Filter only available apps and map to just the mount function. - const appMounters = new Map( - [...this.apps$.value] - .filter(([id]) => availableApps.has(id)) - .map(([id, { mount }]) => [id, mount]) - ); - - return ( - - ); + this.navigate!(getAppUrl(availableMounters, appId, path), state); + this.currentAppId$!.next(appId); }, + getComponent: () => + this.history ? : null, }; } public stop() {} } - -const appPath = (appId: string, { path }: { path?: string } = {}): string => - path - ? `/app/${appId}/${path.replace(/^\//, '')}` // Remove preceding slash from path if present - : `/app/${appId}`; diff --git a/src/core/public/application/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 29c3275f0e3b2..54aaa31e08859 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -17,15 +17,9 @@ * under the License. */ import { CapabilitiesService, CapabilitiesStart } from './capabilities_service'; -import { deepFreeze } from '../../../utils/'; -import { App, LegacyApp } from '../types'; +import { deepFreeze } from '../../../utils'; -const createStartContractMock = ( - apps: ReadonlyMap = new Map(), - legacyApps: ReadonlyMap = new Map() -): jest.Mocked => ({ - availableApps: apps, - availableLegacyApps: legacyApps, +const createStartContractMock = (): jest.Mocked => ({ capabilities: deepFreeze({ catalogue: {}, management: {}, @@ -33,11 +27,8 @@ const createStartContractMock = ( }), }); -type CapabilitiesServiceContract = PublicMethodsOf; -const createMock = (): jest.Mocked => ({ - start: jest - .fn() - .mockImplementation(({ apps, legacyApps }) => createStartContractMock(apps, legacyApps)), +const createMock = (): jest.Mocked> => ({ + start: jest.fn().mockImplementation(createStartContractMock), }); export const capabilitiesServiceMock = { diff --git a/src/core/public/application/capabilities/capabilities_service.test.ts b/src/core/public/application/capabilities/capabilities_service.test.ts index e80e9a7af321a..7b9dce036f053 100644 --- a/src/core/public/application/capabilities/capabilities_service.test.ts +++ b/src/core/public/application/capabilities/capabilities_service.test.ts @@ -19,7 +19,6 @@ import { InjectedMetadataService } from '../../injected_metadata'; import { CapabilitiesService } from './capabilities_service'; -import { LegacyApp, App } from '../types'; describe('#start', () => { const injectedMetadata = new InjectedMetadataService({ @@ -40,31 +39,9 @@ describe('#start', () => { } as any, }).start(); - const apps = new Map([ - ['app1', { id: 'app1' }], - ['app2', { id: 'app2', capabilities: { app2: { feature: true } } }], - ] as Array<[string, App]>); - const legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['legacyApp2', { id: 'legacyApp2', capabilities: { app2: { feature: true } } }], - ] as Array<[string, LegacyApp]>); - - it('filters available apps based on returned navLinks', async () => { - const service = new CapabilitiesService(); - const startContract = await service.start({ apps, legacyApps, injectedMetadata }); - expect(startContract.availableApps).toEqual(new Map([['app1', { id: 'app1' }]])); - expect(startContract.availableLegacyApps).toEqual( - new Map([['legacyApp1', { id: 'legacyApp1' }]]) - ); - }); - it('does not allow Capabilities to be modified', async () => { const service = new CapabilitiesService(); - const { capabilities } = await service.start({ - apps, - legacyApps, - injectedMetadata, - }); + const { capabilities } = await service.start({ injectedMetadata }); // @ts-ignore TypeScript knows this shouldn't be possible expect(() => (capabilities.foo = 'foo')).toThrowError(); diff --git a/src/core/public/application/capabilities/capabilities_service.tsx b/src/core/public/application/capabilities/capabilities_service.tsx index b080f8c138cf2..5f6aede4117bf 100644 --- a/src/core/public/application/capabilities/capabilities_service.tsx +++ b/src/core/public/application/capabilities/capabilities_service.tsx @@ -18,12 +18,9 @@ */ import { deepFreeze, RecursiveReadonly } from '../../../utils'; -import { LegacyApp, App } from '../types'; import { InjectedMetadataStart } from '../../injected_metadata'; interface StartDeps { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; injectedMetadata: InjectedMetadataStart; } @@ -31,7 +28,6 @@ interface StartDeps { * The read-only set of capabilities available for the current UI session. * Capabilities are simple key-value pairs of (string, boolean), where the string denotes the capability ID, * and the boolean is a flag indicating if the capability is enabled or disabled. - * * @public */ export interface Capabilities { @@ -53,8 +49,6 @@ export interface Capabilities { /** @internal */ export interface CapabilitiesStart { capabilities: RecursiveReadonly; - availableApps: ReadonlyMap; - availableLegacyApps: ReadonlyMap; } /** @@ -62,30 +56,9 @@ export interface CapabilitiesStart { * @internal */ export class CapabilitiesService { - public async start({ - apps, - legacyApps, - injectedMetadata, - }: StartDeps): Promise { + public async start({ injectedMetadata }: StartDeps): Promise { const capabilities = deepFreeze(injectedMetadata.getCapabilities()); - const availableApps = new Map( - [...apps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); - - const availableLegacyApps = new Map( - [...legacyApps].filter( - ([appId]) => - capabilities.navLinks[appId] === undefined || capabilities.navLinks[appId] === true - ) - ); - return { - availableApps, - availableLegacyApps, - capabilities, - }; + return { capabilities }; } } diff --git a/src/core/public/application/integration_tests/application_service.test.tsx b/src/core/public/application/integration_tests/application_service.test.tsx new file mode 100644 index 0000000000000..c0ab4d3e8d868 --- /dev/null +++ b/src/core/public/application/integration_tests/application_service.test.tsx @@ -0,0 +1,103 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Subject } from 'rxjs'; +import { bufferCount, skip, takeUntil } from 'rxjs/operators'; + +import { injectedMetadataServiceMock } from '../../injected_metadata/injected_metadata_service.mock'; +import { contextServiceMock } from '../../context/context_service.mock'; +import { httpServiceMock } from '../../http/http_service.mock'; +import { MockLifecycle } from '../test_types'; +import { ApplicationService } from '../application_service'; +import { createRenderer } from './utils'; + +describe('#start()', () => { + let setupDeps: MockLifecycle<'setup'>; + let startDeps: MockLifecycle<'start'>; + let service: ApplicationService; + + beforeEach(() => { + setupDeps = { + context: contextServiceMock.createSetupContract(), + http: httpServiceMock.createSetupContract(), + injectedMetadata: injectedMetadataServiceMock.createSetupContract(), + }; + setupDeps.injectedMetadata.getCapabilities.mockReturnValue({ navLinks: {} } as any); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(false); + startDeps = { injectedMetadata: setupDeps.injectedMetadata }; + service = new ApplicationService(); + }); + + describe('navigateToApp', () => { + it('updates currentApp$ after mounting', async () => { + service.setup(setupDeps); + + const application = await service.start(startDeps); + const stop$ = new Subject(); + const promise = application.currentAppId$ + .pipe(skip(1), bufferCount(3), takeUntil(stop$)) + .toPromise(); + const render = createRenderer(application.getComponent(), application.navigateToApp); + + await render('myTestApp'); + await render('myOtherApp'); + await render('myLastApp'); + stop$.next(); + + const appIds = await promise; + + expect(appIds).toMatchInlineSnapshot(` + Array [ + "myTestApp", + "myOtherApp", + "myLastApp", + ] + `); + }); + + it('sets window.location.href when navigating to legacy apps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + service.setup(setupDeps); + + const application = await service.start(startDeps); + const render = createRenderer(application.getComponent(), application.navigateToApp); + + await render('legacyApp1'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/legacyApp1'); + }); + + it('handles legacy apps with subapps', async () => { + setupDeps.http = httpServiceMock.createSetupContract({ basePath: '/test' }); + setupDeps.injectedMetadata.getLegacyMode.mockReturnValue(true); + setupDeps.redirectTo = jest.fn(); + + const { registerLegacyApp } = service.setup(setupDeps); + + registerLegacyApp({ id: 'baseApp:legacyApp1' } as any); + + const application = await service.start(startDeps); + const render = createRenderer(application.getComponent(), application.navigateToApp); + + await render('baseApp:legacyApp1'); + expect(setupDeps.redirectTo).toHaveBeenCalledWith('/test/app/baseApp'); + }); + }); +}); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 593858851d387..a69af12892350 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -18,107 +18,81 @@ */ import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; import { createMemoryHistory, History } from 'history'; -import { BehaviorSubject } from 'rxjs'; -import { I18nProvider } from '@kbn/i18n/react'; - -import { AppMounter, LegacyApp, AppMountParameters } from '../types'; -import { httpServiceMock } from '../../http/http_service.mock'; import { AppRouter, AppNotFound } from '../ui'; - -const createMountHandler = (htmlString: string) => - jest.fn(async ({ appBasePath: basename, element: el }: AppMountParameters) => { - el.innerHTML = `
\nbasename: ${basename}\nhtml: ${htmlString}\n
`; - return jest.fn(() => (el.innerHTML = '')); - }); +import { EitherApp, MockedMounterMap, MockedMounterTuple } from '../test_types'; +import { createRenderer, createAppMounter, createLegacyAppMounter } from './utils'; describe('AppContainer', () => { - let apps: Map, Parameters>>; - let legacyApps: Map; + let mounters: MockedMounterMap; let history: History; - let router: ReactWrapper; - let redirectTo: jest.Mock; - let currentAppId$: BehaviorSubject; - - const navigate = async (path: string) => { - history.push(path); - router.update(); - // flushes any pending promises - return new Promise(resolve => setImmediate(resolve)); - }; + let navigate: ReturnType; beforeEach(() => { - redirectTo = jest.fn(); - apps = new Map([ - ['app1', createMountHandler('App 1')], - ['app2', createMountHandler('
App 2
')], - ]); - legacyApps = new Map([ - ['legacyApp1', { id: 'legacyApp1' }], - ['baseApp:legacyApp2', { id: 'baseApp:legacyApp2' }], - ]) as Map; + mounters = new Map([ + createAppMounter('app1', 'App 1'), + createLegacyAppMounter('legacyApp1', jest.fn()), + createAppMounter('app2', '
App 2
'), + createLegacyAppMounter('baseApp:legacyApp2', jest.fn()), + createAppMounter('app3', '
App 3
', '/custom/path'), + ] as Array>); history = createMemoryHistory(); - currentAppId$ = new BehaviorSubject(undefined); - // Use 'asdf' as the basepath - const http = httpServiceMock.createStartContract({ basePath: '/asdf' }); - router = mount( - - - - ); + navigate = createRenderer(, history.push); }); - it('calls mountHandler and returned unmount function when navigating between apps', async () => { - await navigate('/app/app1'); - expect(apps.get('app1')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + it('calls mount handler and returned unmount function when navigating between apps', async () => { + const dom1 = await navigate('/app/app1'); + const app1 = mounters.get('app1')!; + + expect(app1.mount).toHaveBeenCalled(); + expect(dom1?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app1 + basename: /app/app1 html: App 1
" `); - const app1Unmount = await apps.get('app1')!.mock.results[0].value; - await navigate('/app/app2'); - expect(app1Unmount).toHaveBeenCalled(); + const app1Unmount = await app1.mount.mock.results[0].value; + const dom2 = await navigate('/app/app2'); - expect(apps.get('app2')!).toHaveBeenCalled(); - expect(router.html()).toMatchInlineSnapshot(` + expect(app1Unmount).toHaveBeenCalled(); + expect(mounters.get('app2')!.mount).toHaveBeenCalled(); + expect(dom2?.html()).toMatchInlineSnapshot(` "
- basename: /asdf/app/app2 + basename: /app/app2 html:
App 2
" `); }); - it('updates currentApp$ after mounting', async () => { - await navigate('/app/app1'); - expect(currentAppId$.value).toEqual('app1'); - await navigate('/app/app2'); - expect(currentAppId$.value).toEqual('app2'); - }); - - it('sets window.location.href when navigating to legacy apps', async () => { + it('calls legacy mount handler', async () => { await navigate('/app/legacyApp1'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/legacyApp1'); + expect(mounters.get('legacyApp1')!.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/legacyApp1", + "element":
, + }, + ] + `); }); it('handles legacy apps with subapps', async () => { await navigate('/app/baseApp'); - expect(redirectTo).toHaveBeenCalledWith('/asdf/app/baseApp'); + expect(mounters.get('baseApp:legacyApp2')!.mount.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "appBasePath": "/app/baseApp", + "element":
, + }, + ] + `); }); it('displays error page if no app is found', async () => { - await navigate('/app/unknown'); - expect(router.exists(AppNotFound)).toBe(true); + const dom = await navigate('/app/unknown'); + + expect(dom?.exists(AppNotFound)).toBe(true); }); }); diff --git a/src/core/public/application/integration_tests/utils.tsx b/src/core/public/application/integration_tests/utils.tsx new file mode 100644 index 0000000000000..78b17ed30243e --- /dev/null +++ b/src/core/public/application/integration_tests/utils.tsx @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Component, ReactNode } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { App, LegacyApp, AppMountParameters } from '../types'; +import { MockedMounter, MockedMounterTuple } from '../test_types'; + +type Renderer = ( + item: string +) => Promise, Component<{}, {}, any>> | null>; + +export const createRenderer = ( + node: ReactNode | null, + callback: (item: string) => void +): Renderer => { + const dom = node !== null ? mount({node}) : node; + + return (item: string) => { + callback(item); + if (dom) { + dom.update(); + } + return new Promise(resolve => setImmediate(() => resolve(dom))); // flushes any pending promises + }; +}; + +export const createAppMounter = ( + appId: string, + html: string, + appBasePath = `/app/${appId}` +): MockedMounterTuple => [ + appId, + { + appBasePath, + mount: jest.fn(async ({ appBasePath: basename, element }: AppMountParameters) => { + Object.assign(element, { + innerHTML: `
\nbasename: ${basename}\nhtml: ${html}\n
`, + }); + return jest.fn(() => Object.assign(element, { innerHTML: '' })); + }), + }, +]; + +export const createLegacyAppMounter = ( + appId: string, + legacyMount: MockedMounter['mount'] +): MockedMounterTuple => [ + appId, + { + appBasePath: `/app/${appId.split(':')[0]}`, + unmountBeforeMounting: true, + mount: legacyMount, + }, +]; diff --git a/src/core/public/application/test_types.ts b/src/core/public/application/test_types.ts new file mode 100644 index 0000000000000..f5fb639eaa32c --- /dev/null +++ b/src/core/public/application/test_types.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { App, LegacyApp, Mounter } from './types'; +import { ApplicationService } from './application_service'; + +/** @internal */ +export type ApplicationServiceContract = PublicMethodsOf; +/** @internal */ +export type EitherApp = App | LegacyApp; +/** @internal */ +export type MockedMounter = jest.Mocked>>; +/** @internal */ +export type MockedMounterTuple = [string, MockedMounter]; +/** @internal */ +export type MockedMounterMap = Map>; +/** @internal */ +export type MockLifecycle< + T extends keyof ApplicationService, + U = Parameters[0] +> = { [P in keyof U]: jest.Mocked }; diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 5be22ea151c32..aa52dbef08e32 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -86,6 +86,12 @@ export interface App extends AppBase { * Takes precedence over chrome service visibility settings. */ chromeless?: boolean; + + /** + * Override the application's base routing path from `/app/${id}`. + * Must be unique across registered applications. + */ + appBasePath?: string; } /** @internal */ @@ -192,6 +198,19 @@ export type AppUnmount = () => void; /** @internal */ export type AppMounter = (params: AppMountParameters) => Promise; +/** @internal */ +export type LegacyAppMounter = (params: AppMountParameters) => void; + +/** @internal */ +export type Mounter = SelectivePartial< + { + appBasePath: string; + mount: T extends LegacyApp ? LegacyAppMounter : AppMounter; + unmountBeforeMounting: T extends LegacyApp ? true : boolean; + }, + T extends LegacyApp ? never : 'unmountBeforeMounting' +>; + /** @public */ export interface ApplicationSetup { /** @@ -314,3 +333,9 @@ export interface InternalApplicationStart currentAppId$: Subject; getComponent(): JSX.Element | null; } + +/** @internal */ +type SelectivePartial = Partial> & + Required>> extends infer U + ? { [P in keyof U]: U[P] } + : never; diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index 876cd3aa3a3d3..96ee91c7c21fb 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -17,95 +17,60 @@ * under the License. */ -import React from 'react'; -import { RouteComponentProps } from 'react-router-dom'; -import { Subject } from 'rxjs'; - -import { LegacyApp, AppMounter, AppUnmount } from '../types'; -import { HttpStart } from '../../http'; +import React, { + Fragment, + FunctionComponent, + useLayoutEffect, + useRef, + useState, + MutableRefObject, +} from 'react'; + +import { AppUnmount, Mounter } from '../types'; import { AppNotFound } from './app_not_found_screen'; -interface Props extends RouteComponentProps<{ appId: string }> { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo: (path: string) => void; -} - -interface State { - appNotFound: boolean; -} - -export class AppContainer extends React.Component { - private readonly containerDiv = React.createRef(); - private unmountFunc?: AppUnmount; - - state: State = { appNotFound: false }; - - componentDidMount() { - this.mountApp(); - } - - componentWillUnmount() { - this.unmountApp(); - } - - componentDidUpdate(prevProps: Props) { - if (prevProps.match.params.appId !== this.props.match.params.appId) { - this.unmountApp(); - this.mountApp(); - } - } - - async mountApp() { - const { apps, legacyApps, match, basePath, currentAppId$, redirectTo } = this.props; - const { appId } = match.params; - - const mount = apps.get(appId); - if (mount) { - this.unmountFunc = await mount({ - appBasePath: basePath.prepend(`/app/${appId}`), - element: this.containerDiv.current!, - }); - currentAppId$.next(appId); - this.setState({ appNotFound: false }); - return; - } - - const legacyApp = findLegacyApp(appId, legacyApps); - if (legacyApp) { - this.unmountApp(); - redirectTo(basePath.prepend(`/app/${appId}`)); - this.setState({ appNotFound: false }); - return; - } - - this.setState({ appNotFound: true }); - } - - async unmountApp() { - if (this.unmountFunc) { - this.unmountFunc(); - this.unmountFunc = undefined; - } - } - - render() { - return ( - - {this.state.appNotFound && } -
- - ); - } +interface Props { + appId: string; + mounter?: Mounter; } -function findLegacyApp(appId: string, apps: ReadonlyMap) { - const matchingApps = [...apps.entries()].filter(([id]) => id.split(':')[0] === appId); - return matchingApps.length ? matchingApps[0][1] : null; -} +export const AppContainer: FunctionComponent = ({ mounter, appId }: Props) => { + const [appNotFound, setAppNotFound] = useState(false); + const elementRef = useRef(null); + const unmountRef: MutableRefObject = useRef(null); + + useLayoutEffect(() => { + const unmount = () => { + if (unmountRef.current) { + unmountRef.current(); + unmountRef.current = null; + } + }; + const mount = async () => { + if (!mounter) { + return setAppNotFound(true); + } + + if (mounter.unmountBeforeMounting) { + unmount(); + } + + unmountRef.current = + (await mounter.mount({ + appBasePath: mounter.appBasePath, + element: elementRef.current!, + })) || null; + setAppNotFound(false); + }; + + mount(); + return unmount; + }); + + return ( + + {appNotFound && } +
+ + ); +}; diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index b574bf16278e2..e5e91615f4798 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -17,37 +17,53 @@ * under the License. */ +import React, { Fragment, FunctionComponent } from 'react'; import { History } from 'history'; -import React from 'react'; -import { Router, Route } from 'react-router-dom'; -import { Subject } from 'rxjs'; +import { Router, Route, RouteComponentProps } from 'react-router-dom'; -import { LegacyApp, AppMounter } from '../types'; +import { Mounter } from '../types'; import { AppContainer } from './app_container'; -import { HttpStart } from '../../http'; interface Props { - apps: ReadonlyMap; - legacyApps: ReadonlyMap; - basePath: HttpStart['basePath']; - currentAppId$: Subject; + mounters: Map; history: History; - /** - * Only necessary for redirecting to legacy apps - * @deprecated - */ - redirectTo?: (path: string) => void; } -export const AppRouter: React.FunctionComponent = ({ - history, - redirectTo = (path: string) => (window.location.href = path), - ...otherProps -}) => ( +interface Params { + appId: string; +} + +export const AppRouter: FunctionComponent = ({ history, mounters }) => ( - } - /> + + {[...mounters].flatMap(([appId, mounter]) => + // Remove /app paths from the routes as they will be handled by the + // "named" route parameter `:appId` below + mounter.appBasePath.startsWith('/app') + ? [] + : [ + } + />, + ] + )} + ) => { + // Find the mounter including legacy mounters with subapps: + const [id, mounter] = mounters.has(appId) + ? [appId, mounters.get(appId)] + : [...mounters].filter(([key]) => key.startsWith(`${appId}:`))[0] ?? []; + + return ; + }} + /> + ); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 2404459ad1383..ff4c64aa3611c 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -174,7 +174,7 @@ export class CoreSystem { [this.legacy.legacyId, [...pluginDependencies.keys()]], ]), }); - const application = this.application.setup({ context }); + const application = this.application.setup({ context, http, injectedMetadata }); const core: InternalCoreSetup = { application, @@ -214,7 +214,7 @@ export class CoreSystem { const http = await this.http.start({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const savedObjects = await this.savedObjects.start({ http }); const i18n = await this.i18n.start(); - const application = await this.application.start({ http, injectedMetadata }); + const application = await this.application.start({ injectedMetadata }); await this.integrations.start({ uiSettings }); const coreUiTargetDomElement = document.createElement('div'); diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 16a634b2d3287..ea94738e89ad1 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -18,6 +18,7 @@ import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/type // @public export interface App extends AppBase { + appBasePath?: string; chromeless?: boolean; mount: (context: AppMountContext, params: AppMountParameters) => AppUnmount | Promise; } diff --git a/src/core/server/application/application_context_provider.mock.ts b/src/core/server/application/application_context_provider.mock.ts new file mode 100644 index 0000000000000..803944c90eff4 --- /dev/null +++ b/src/core/server/application/application_context_provider.mock.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {} from './types'; +import { ApplicationContextProvider } from './application_context_provider'; + +type ApplicationContextProviderMock = jest.Mocked>; + +const createApplicationContextProviderMock = (): ApplicationContextProviderMock => ({ + render: jest.fn(), +}); + +export const applicationContextProviderMock = { + create: createApplicationContextProviderMock, +}; diff --git a/src/core/server/application/application_context_provider.test.mocks.ts b/src/core/server/application/application_context_provider.test.mocks.ts new file mode 100644 index 0000000000000..8491e2fbbfa51 --- /dev/null +++ b/src/core/server/application/application_context_provider.test.mocks.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { applicationContextProviderMock } from './application_context_provider.mock'; + +export const MockApplicationContextProvider = applicationContextProviderMock.create(); + +jest.doMock('./application_context_provider', () => ({ + ApplicationContextProvider: jest.fn().mockImplementation(() => MockApplicationContextProvider), +})); diff --git a/src/core/server/application/application_context_provider.test.ts b/src/core/server/application/application_context_provider.test.ts new file mode 100644 index 0000000000000..20d63ff94ac91 --- /dev/null +++ b/src/core/server/application/application_context_provider.test.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockCoreContext } from '../core_context.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; +import { pluginServiceMock } from '../plugins/plugins_service.mock'; +import { legacyServiceMock } from '../legacy/legacy_service.mock'; +import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; +import { ApplicationContextProvider } from './application_context_provider'; + +const RESPONSE_SNAPSHOT = ` + Object { + "body": Any, + "headers": Object { + "content-security-policy": "script-src 'unsafe-eval' 'self'; worker-src blob:; child-src blob:; style-src 'unsafe-inline' 'self'", + }, + } +`; +const MATCHES_CORE = /app:core/; +const MATCHES_FAKE_VAR = /"fake":"__TEST_TOKEN__"/; + +describe('ApplicationContextProvider', () => { + let context: ReturnType; + let http: ReturnType; + let plugins: ReturnType; + let legacy: ReturnType; + let request: ReturnType; + let response: ReturnType; + let uiSettingsClient: ReturnType; + let provider: ApplicationContextProvider; + + beforeEach(async () => { + context = mockCoreContext.create(); + http = httpServiceMock.createSetupContract(); + plugins = pluginServiceMock.createSetupContract(); + legacy = legacyServiceMock.createSetupContract(); + request = httpServerMock.createKibanaRequest(); + response = httpServerMock.createResponseFactory(); + uiSettingsClient = uiSettingsServiceMock.createClient(); + provider = new ApplicationContextProvider({ + env: context.env, + http, + legacy, + plugins, + request, + response, + uiSettings: await uiSettingsClient.getAll(), + }); + }); + + describe('#render()', () => { + it('renders "core" application', async () => { + await provider.render('core'); + expect(response.ok).toHaveBeenCalled(); + + const options = response.ok.mock.calls[0][0]; + + expect(options).toMatchInlineSnapshot({ body: expect.any(String) }, RESPONSE_SNAPSHOT); + expect(options!.body).toMatch(MATCHES_CORE); + }); + + it('renders fallback to "core" application when appId not found', async () => { + await provider.render('fake'); + expect(response.ok).toHaveBeenCalled(); + + const options = response.ok.mock.calls[0][0]; + + expect(options).toMatchInlineSnapshot({ body: expect.any(String) }, RESPONSE_SNAPSHOT); + expect(options!.body).toMatch(MATCHES_CORE); + }); + + it('renders available application', async () => { + http.server.getUiAppById = jest.fn(appId => ({ getId: () => appId })); + await provider.render('test'); + expect(response.ok).toHaveBeenCalled(); + + const options = response.ok.mock.calls[0][0]; + + expect(options).toMatchInlineSnapshot({ body: expect.any(String) }, RESPONSE_SNAPSHOT); + expect(options!.body).toMatch(/app:test/); + }); + + it('renders with custom injectedVarsOverrides', async () => { + http.server.getUiAppById = jest.fn(appId => ({ getId: () => appId })); + await provider.render('core', { fake: '__TEST_TOKEN__' }); + expect(response.ok).toHaveBeenCalled(); + + const options = response.ok.mock.calls[0][0]; + + expect(options).toMatchInlineSnapshot({ body: expect.any(String) }, RESPONSE_SNAPSHOT); + expect(options!.body).toMatch(MATCHES_FAKE_VAR); + }); + }); +}); diff --git a/src/core/server/application/application_context_provider.ts b/src/core/server/application/application_context_provider.ts new file mode 100644 index 0000000000000..b6d1d5a364cea --- /dev/null +++ b/src/core/server/application/application_context_provider.ts @@ -0,0 +1,175 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { compileFile } from 'pug'; +import { join } from 'path'; + +import { i18n } from '@kbn/i18n'; + +import { LegacyServiceSetup } from '../legacy'; +import { Env } from '../config'; +import { InternalHttpServiceSetup, KibanaRequest, KibanaResponseFactory } from '../http'; +import { PluginsServiceSetup } from '../plugins'; +import { createCspRuleString } from '../csp'; +import { mergeCapabilities } from './capabilities'; +import { mergeVariables } from './merge_variables'; +import { IApplicationContextProvider } from './types'; + +interface Params { + request: KibanaRequest; + response: KibanaResponseFactory; + env: Env; + legacy: LegacyServiceSetup; + http: InternalHttpServiceSetup; + uiSettings: Record; + plugins: PluginsServiceSetup; +} + +interface PluginSpec { + readConfigValue: (config: any, key: any) => any; +} + +interface Provider { + fn: ( + server: InternalHttpServiceSetup['server'], + config: ReturnType + ) => T; + pluginSpec: PluginSpec; +} + +type App = ReturnType; +type Replacer = ( + variables: T, + request: KibanaRequest, + server: InternalHttpServiceSetup['server'] +) => Promise | T; + +export class ApplicationContextProvider implements IApplicationContextProvider { + private readonly request!: KibanaRequest; + private readonly response!: KibanaResponseFactory; + private readonly env!: Env; + private readonly legacy!: LegacyServiceSetup; + private readonly http!: InternalHttpServiceSetup; + private readonly uiSettings!: Record; + private readonly plugins!: PluginsServiceSetup; + private readonly template = compileFile(join(__dirname, 'views/ui_app.pug')); + + constructor(params: Params) { + Object.assign(this, params); + } + + private getCapabilities() { + const modifiers = this.http.server.getCapabilitiesModifiers(); + const defaultCapabilties = this.http.server.getDefaultCapabilities(); + const capabilities = mergeCapabilities(defaultCapabilties, { + // Get legacy nav links + navLinks: Object.assign( + {}, + ...this.http.server.getUiNavLinks().map(({ _id }) => ({ [_id]: true })) + ), + }); + + return modifiers.reduce( + async (resolvedCapabilties, modifier) => modifier(this.request, await resolvedCapabilties), + Promise.resolve(capabilities) + ); + } + + private async getVariables(app: App, basePath: string, injectedOverrides: Record) { + const providers: Provider[] = (this.legacy.uiExports as any).defaultInjectedVarProviders || []; + const replacers: Replacer[] = (this.legacy.uiExports as any).injectedVarsReplacers || []; + const defaultInjectedVars = providers.reduce( + (defaults, { fn, pluginSpec }) => + mergeVariables( + defaults, + fn(this.http.server, pluginSpec.readConfigValue(this.http.server.config, [])) + ), + {} + ); + const appInjectedVars = await this.http.server.getInjectedUiAppVars(app.getId()); + const injectedVars = mergeVariables(defaultInjectedVars, appInjectedVars, injectedOverrides); + const vars = await replacers.reduce( + async (variables, replacer) => await replacer(variables, this.request, this.http.server), + Promise.resolve(injectedVars) + ); + + return { + strictCsp: this.http.csp.strict, + uiPublicUrl: `${basePath}/ui`, + bootstrapScriptUrl: `${basePath}/bundles/app/${app.getId()}/bootstrap.js`, + i18n: i18n.translate, + locale: i18n.getLocale(), + darkMode: this.uiSettings?.user?.['theme:darkMode']?.userValue ?? false, + injectedMetadata: { + version: this.http.server.version, + buildNumber: this.env.packageInfo.buildNum, + branch: this.env.packageInfo.branch, + basePath, + env: this.env, + legacyMode: app.getId() !== 'core', + i18n: { + translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, + }, + csp: { + warnLegacyBrowsers: this.http.csp.warnLegacyBrowsers, + }, + vars, + uiPlugins: [...this.plugins.uiPlugins.public].map(([id, plugin]) => ({ id, plugin })), + legacyMetadata: { + app, + bundleId: `app:${app.getId()}`, + nav: this.http.server.getUiNavLinks(), + env: this.env, + version: this.env.packageInfo.version, + branch: this.env.packageInfo.branch, + buildNum: this.env.packageInfo.buildNum, + buildSha: this.env.packageInfo.buildSha, + serverName: this.http.server.name, + devMode: this.env.mode.dev, + basePath, + uiSettings: this.uiSettings, + }, + capabilities: await this.getCapabilities(), + }, + }; + } + + public async render(appId: string, injectedVarsOverrides: Record = {}) { + try { + const app = this.http.server.getUiAppById(appId) || { getId: () => 'core' }; + const variables = await this.getVariables( + app, + this.http.basePath.get(this.request), + injectedVarsOverrides + ); + const content = this.template(variables); + + return this.response.ok({ + body: content, + headers: { + 'content-security-policy': createCspRuleString(this.http.csp.rules), + }, + }); + } catch (err) { + return this.response.internalError({ + body: `Unable to render application with the id ${appId}`, + }); + } + } +} diff --git a/src/core/server/application/application_service.mock.ts b/src/core/server/application/application_service.mock.ts new file mode 100644 index 0000000000000..8bd6db76ba9b6 --- /dev/null +++ b/src/core/server/application/application_service.mock.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ApplicationServiceSetup } from './types'; +import { ApplicationService } from './application_service'; +import { applicationContextProviderMock } from './application_context_provider.mock'; + +type ApplicationServiceMock = jest.Mocked>; +type ApplicationServiceSetupMock = jest.Mocked; + +const createSetupContractMock = (): ApplicationServiceSetupMock => ({ + getCoreContextProvider: jest.fn().mockResolvedValue(applicationContextProviderMock.create()), +}); +const createApplicationServiceMock = (): ApplicationServiceMock => ({ + setup: jest.fn().mockResolvedValue(createSetupContractMock()), + start: jest.fn(), + stop: jest.fn(), +}); + +export const applicationServiceMock = { + create: createApplicationServiceMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/core/server/application/application_service.test.ts b/src/core/server/application/application_service.test.ts new file mode 100644 index 0000000000000..b562e3d8ff1f9 --- /dev/null +++ b/src/core/server/application/application_service.test.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockCoreContext } from '../core_context.mock'; +import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; +import { pluginServiceMock } from '../plugins/plugins_service.mock'; +import { legacyServiceMock } from '../legacy/legacy_service.mock'; +import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; +import { MockApplicationContextProvider } from './application_context_provider.test.mocks'; +import { ApplicationService } from './application_service'; + +describe('ApplicationService', () => { + let context: ReturnType; + let http: ReturnType; + let plugins: ReturnType; + let legacy: ReturnType; + let service: ApplicationService; + + beforeEach(() => { + context = mockCoreContext.create(); + service = new ApplicationService(context); + http = httpServiceMock.createSetupContract(); + legacy = legacyServiceMock.createSetupContract(); + }); + + describe('getCoreContextProvider', () => { + it('creates instance of ApplicationContextProvider', async () => { + const { getCoreContextProvider } = await service.setup({ http, plugins, legacy }); + + await expect( + getCoreContextProvider({ + request: httpServerMock.createKibanaRequest(), + response: httpServerMock.createResponseFactory(), + uiSettingsClient: uiSettingsServiceMock.createClient(), + }) + ).resolves.toBe(MockApplicationContextProvider); + }); + }); +}); diff --git a/src/core/server/application/application_service.ts b/src/core/server/application/application_service.ts new file mode 100644 index 0000000000000..4b5bbca478ab4 --- /dev/null +++ b/src/core/server/application/application_service.ts @@ -0,0 +1,54 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreService } from '../../types'; +import { LegacyServiceSetup } from '../legacy'; +import { CoreContext } from '../core_context'; +import { InternalHttpServiceSetup } from '../http'; +import { PluginsServiceSetup } from '../plugins'; +import { ApplicationServiceSetup } from './types'; +import { ApplicationContextProvider } from './application_context_provider'; + +interface SetupDeps { + http: InternalHttpServiceSetup; + plugins: PluginsServiceSetup; + legacy: LegacyServiceSetup; +} + +/** @internal */ +export class ApplicationService implements CoreService { + constructor(private readonly coreContext: CoreContext) {} + + public async setup(deps: SetupDeps): Promise { + return { + getCoreContextProvider: async ({ request, response, uiSettingsClient }) => + new ApplicationContextProvider({ + ...deps, + request, + response, + uiSettings: await uiSettingsClient.getAll(), + env: this.coreContext.env, + }), + }; + } + + public async start() {} + + public async stop() {} +} diff --git a/src/core/server/application/capabilities/index.ts b/src/core/server/application/capabilities/index.ts new file mode 100644 index 0000000000000..7f04879bac3cc --- /dev/null +++ b/src/core/server/application/capabilities/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { mergeCapabilities } from './merge_capabilities'; +export * from './types'; diff --git a/src/core/server/application/capabilities/merge_capabilities.test.ts b/src/core/server/application/capabilities/merge_capabilities.test.ts new file mode 100644 index 0000000000000..b081706558a9a --- /dev/null +++ b/src/core/server/application/capabilities/merge_capabilities.test.ts @@ -0,0 +1,86 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mergeCapabilities } from './merge_capabilities'; + +const defaultProps = { + catalogue: {}, + management: {}, + navLinks: {}, +}; + +describe('mergeCapabilities', () => { + it(`"{ foo: {} }" doesn't clobber "{ foo: { bar: true } }"`, () => { + const output1 = mergeCapabilities({ foo: { bar: true } }, { foo: {} }); + expect(output1).toEqual({ ...defaultProps, foo: { bar: true } }); + + const output2 = mergeCapabilities({ foo: { bar: true } }, { foo: {} }); + expect(output2).toEqual({ ...defaultProps, foo: { bar: true } }); + }); + + it(`"{ foo: { bar: true } }" doesn't clobber "{ baz: { quz: true } }"`, () => { + const output1 = mergeCapabilities({ foo: { bar: true } }, { baz: { quz: true } }); + expect(output1).toEqual({ ...defaultProps, foo: { bar: true }, baz: { quz: true } }); + + const output2 = mergeCapabilities({ baz: { quz: true } }, { foo: { bar: true } }); + expect(output2).toEqual({ ...defaultProps, foo: { bar: true }, baz: { quz: true } }); + }); + + it(`"{ foo: { bar: { baz: true } } }" doesn't clobber "{ foo: { bar: { quz: true } } }"`, () => { + const output1 = mergeCapabilities( + { foo: { bar: { baz: true } } }, + { foo: { bar: { quz: true } } } + ); + expect(output1).toEqual({ ...defaultProps, foo: { bar: { baz: true, quz: true } } }); + + const output2 = mergeCapabilities( + { foo: { bar: { quz: true } } }, + { foo: { bar: { baz: true } } } + ); + expect(output2).toEqual({ ...defaultProps, foo: { bar: { baz: true, quz: true } } }); + }); + + it(`error is thrown if boolean and object clash`, () => { + expect(() => { + mergeCapabilities({ foo: { bar: { baz: true } } }, { foo: { bar: true } }); + }).toThrowErrorMatchingInlineSnapshot(`"a boolean and an object can't be merged"`); + + expect(() => { + mergeCapabilities({ foo: { bar: true } }, { foo: { bar: { baz: true } } }); + }).toThrowErrorMatchingInlineSnapshot(`"a boolean and an object can't be merged"`); + }); + + it(`supports duplicates as long as the booleans are the same`, () => { + const output1 = mergeCapabilities({ foo: { bar: true } }, { foo: { bar: true } }); + expect(output1).toEqual({ ...defaultProps, foo: { bar: true } }); + + const output2 = mergeCapabilities({ foo: { bar: false } }, { foo: { bar: false } }); + expect(output2).toEqual({ ...defaultProps, foo: { bar: false } }); + }); + + it(`error is thrown if merging "true" and "false"`, () => { + expect(() => { + mergeCapabilities({ foo: { bar: false } }, { foo: { bar: true } }); + }).toThrowErrorMatchingInlineSnapshot(`"\\"true\\" and \\"false\\" can't be merged"`); + + expect(() => { + mergeCapabilities({ foo: { bar: true } }, { foo: { bar: false } }); + }).toThrowErrorMatchingInlineSnapshot(`"\\"true\\" and \\"false\\" can't be merged"`); + }); +}); diff --git a/src/core/server/application/capabilities/merge_capabilities.ts b/src/core/server/application/capabilities/merge_capabilities.ts new file mode 100644 index 0000000000000..15e837a649c9c --- /dev/null +++ b/src/core/server/application/capabilities/merge_capabilities.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import typeDetect from 'type-detect'; +import { merge } from 'lodash'; + +import { Capabilities } from '../../../public'; + +export const mergeCapabilities = (...sources: Array>): Capabilities => + merge( + { + navLinks: {}, + management: {}, + catalogue: {}, + }, + ...sources, + (a: any, b: any) => { + if ( + (typeDetect(a) === 'boolean' && typeDetect(b) === 'Object') || + (typeDetect(b) === 'boolean' && typeDetect(a) === 'Object') + ) { + throw new Error(`a boolean and an object can't be merged`); + } + + if (typeDetect(a) === 'boolean' && typeDetect(b) === 'boolean' && a !== b) { + throw new Error(`"true" and "false" can't be merged`); + } + } + ); diff --git a/src/core/server/application/capabilities/types.ts b/src/core/server/application/capabilities/types.ts new file mode 100644 index 0000000000000..924d85b96570d --- /dev/null +++ b/src/core/server/application/capabilities/types.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Capabilities } from '../../../public'; +import { KibanaRequest } from '../../http'; + +/** @public */ +export type CapabilitiesModifier = ( + request: KibanaRequest, + uiCapabilities: C +) => C | Promise; diff --git a/src/core/server/application/index.ts b/src/core/server/application/index.ts new file mode 100644 index 0000000000000..6e47538fee69f --- /dev/null +++ b/src/core/server/application/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { ApplicationService } from './application_service'; +export * from './types'; +export * from './capabilities/types'; diff --git a/src/core/server/application/merge_variables.test.ts b/src/core/server/application/merge_variables.test.ts new file mode 100644 index 0000000000000..ac922cda206c2 --- /dev/null +++ b/src/core/server/application/merge_variables.test.ts @@ -0,0 +1,199 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mergeVariables } from './merge_variables'; + +describe('mergeVariables', () => { + it('merges two objects together', () => { + const first = { + otherName: 'value', + otherCanFoo: true, + otherNested: { + otherAnotherVariable: 'ok', + }, + }; + const second = { + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + }; + + expect(mergeVariables(first, second)).toEqual({ + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + otherName: 'value', + otherCanFoo: true, + otherNested: { + otherAnotherVariable: 'ok', + }, + }); + }); + + it('does not mutate the source objects', () => { + const first = { + var1: 'first', + }; + const second = { + var1: 'second', + var2: 'second', + }; + const third = { + var1: 'third', + var2: 'third', + var3: 'third', + }; + const fourth = { + var1: 'fourth', + var2: 'fourth', + var3: 'fourth', + var4: 'fourth', + }; + + mergeVariables(first, second, third, fourth); + + expect(first).toEqual({ var1: 'first' }); + expect(second).toEqual({ var1: 'second', var2: 'second' }); + expect(third).toEqual({ var1: 'third', var2: 'third', var3: 'third' }); + expect(fourth).toEqual({ var1: 'fourth', var2: 'fourth', var3: 'fourth', var4: 'fourth' }); + }); + + it('merges multiple objects together with precedence increasing from left-to-right', () => { + const first = { + var1: 'first', + var2: 'first', + var3: 'first', + var4: 'first', + }; + const second = { + var1: 'second', + var2: 'second', + var3: 'second', + }; + const third = { + var1: 'third', + var2: 'third', + }; + const fourth = { + var1: 'fourth', + }; + + expect(mergeVariables(first, second, third, fourth)).toEqual({ + var1: 'fourth', + var2: 'third', + var3: 'second', + var4: 'first', + }); + }); + + it('overwrites the original variable value if a duplicate entry is found', () => { + const first = { + nested: { + otherAnotherVariable: 'ok', + }, + }; + const second = { + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + }; + + expect(mergeVariables(first, second)).toEqual({ + name: 'value', + canFoo: true, + nested: { + anotherVariable: 'ok', + }, + }); + }); + + it('combines entries within "uiCapabilities"', () => { + const first = { + uiCapabilities: { + firstCapability: 'ok', + sharedCapability: 'shared', + }, + }; + const second = { + name: 'value', + canFoo: true, + uiCapabilities: { + secondCapability: 'ok', + }, + }; + const third = { + name: 'value', + canFoo: true, + uiCapabilities: { + thirdCapability: 'ok', + sharedCapability: 'blocked', + }, + }; + + expect(mergeVariables(first, second, third)).toEqual({ + name: 'value', + canFoo: true, + uiCapabilities: { + firstCapability: 'ok', + secondCapability: 'ok', + thirdCapability: 'ok', + sharedCapability: 'blocked', + }, + }); + }); + + it('does not deeply combine entries within "uiCapabilities"', () => { + const first = { + uiCapabilities: { + firstCapability: 'ok', + nestedCapability: { + otherNestedProp: 'otherNestedValue', + }, + }, + }; + const second = { + name: 'value', + canFoo: true, + uiCapabilities: { + secondCapability: 'ok', + nestedCapability: { + nestedProp: 'nestedValue', + }, + }, + }; + + expect(mergeVariables(first, second)).toEqual({ + name: 'value', + canFoo: true, + uiCapabilities: { + firstCapability: 'ok', + secondCapability: 'ok', + nestedCapability: { + nestedProp: 'nestedValue', + }, + }, + }); + }); +}); diff --git a/src/core/server/application/merge_variables.ts b/src/core/server/application/merge_variables.ts new file mode 100644 index 0000000000000..75a6d91098c59 --- /dev/null +++ b/src/core/server/application/merge_variables.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const ELIGIBLE_FLAT_MERGE_KEYS = ['uiCapabilities']; + +/** + * Merges an array of vars objects into a single object. + * Properties in the target object will be overwritten by properties + * in the sources if they have the same key. Certain well-known + * properties that are objects will be merged. + * Precedence of properties increases from left-to-right, similarly + * to Object.assign. + */ +export const mergeVariables = (...sources: Array>) => + Object.assign( + {}, + ...sources, + ...ELIGIBLE_FLAT_MERGE_KEYS.flatMap(key => + sources.some(source => key in source) + ? [{ [key]: Object.assign({}, ...sources.map(source => source[key] || {})) }] + : [] + ) + ); diff --git a/src/core/server/application/types.ts b/src/core/server/application/types.ts new file mode 100644 index 0000000000000..85f3666f75b7e --- /dev/null +++ b/src/core/server/application/types.ts @@ -0,0 +1,39 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { KibanaRequest, KibanaResponseFactory, IKibanaResponse } from '../http'; +import { IUiSettingsClient } from '../ui_settings'; +import { ApplicationContextProvider } from './application_context_provider'; + +/** @internal */ +export interface IApplicationContextProvider { + render: (appId: string, injectedVarsOverrides?: Record) => Promise; +} + +/** @public */ +export interface ApplicationServiceSetup { + /** + * TODO: add comments + */ + getCoreContextProvider: (providers: { + request: KibanaRequest; + response: KibanaResponseFactory; + uiSettingsClient: IUiSettingsClient; + }) => Promise; +} diff --git a/src/core/server/application/views/chrome.pug b/src/core/server/application/views/chrome.pug new file mode 100644 index 0000000000000..9cb99f961767b --- /dev/null +++ b/src/core/server/application/views/chrome.pug @@ -0,0 +1,305 @@ +block vars + +doctype html +html(lang=locale) + head + meta(charset='utf-8') + meta(http-equiv='X-UA-Compatible', content='IE=edge,chrome=1') + meta(name='viewport', content='width=device-width') + title Kibana + style. + /* INTER UI FONT */ + /* INTER UI FONT */ + /* INTER UI FONT */ + /* INTER UI FONT */ + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 100; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Thin-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Thin-BETA.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 100; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ThinItalic-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ThinItalic-BETA.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 200; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraLight-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraLight-BETA.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 200; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraLightItalic-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraLightItalic-BETA.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 300; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Light-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Light-BETA.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 300; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-LightItalic-BETA.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-LightItalic-BETA.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 400; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Regular.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Regular.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 400; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Italic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Italic.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 500; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Medium.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Medium.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 500; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-MediumItalic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-MediumItalic.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 600; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-SemiBold.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-SemiBold.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 600; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-SemiBoldItalic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-SemiBoldItalic.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 700; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Bold.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Bold.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 700; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-BoldItalic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-BoldItalic.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 800; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraBold.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraBold.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 800; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraBoldItalic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-ExtraBoldItalic.woff") format("woff"); + } + + @font-face { + font-family: 'Inter UI'; + font-style: normal; + font-weight: 900; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Black.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-Black.woff") format("woff"); + } + @font-face { + font-family: 'Inter UI'; + font-style: italic; + font-weight: 900; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-BlackItalic.woff2") format("woff2"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-BlackItalic.woff") format("woff"); + } + + /* -------------------------------------------------------------------------- + Single variable font. + + Note that you may want to do something like this to make sure you're serving + constant fonts to older browsers: + html { + font-family: 'Inter UI', sans-serif; + } + @supports (font-variation-settings: normal) { + html { + font-family: 'Inter UI var', sans-serif; + } + } + + BUGS: + - Safari 12.0 will default to italic instead of regular when font-weight + is provided in a @font-face declaration. + Workaround: Use "Inter UI var alt" for Safari, or explicitly set + `font-variation-settings:"slnt" DEGREE`. + @font-face { + font-family: 'Inter UI var'; + font-weight: 100 900; + font-style: oblique 0deg 10deg; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI.var.woff2") format("woff2-variations"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI.var.woff2") format("woff2"); + } + + "Inter UI var alt" is recommended for Safari and Edge, for reliable italics. + + @supports (font-variation-settings: normal) { + html { + font-family: 'Inter UI var alt', sans-serif; + } + } + + @font-face { + font-family: 'Inter UI var alt'; + font-weight: 100 900; + font-style: normal; + font-named-instance: 'Regular'; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-upright.var.woff2") format("woff2 supports variations(gvar)"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-upright.var.woff2") format("woff2-variations"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-upright.var.woff2") format("woff2"); + } + @font-face { + font-family: 'Inter UI var alt'; + font-weight: 100 900; + font-style: italic; + font-named-instance: 'Italic'; + src: url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-italic.var.woff2") format("woff2 supports variations(gvar)"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-italic.var.woff2") format("woff2-variations"), + url("#{uiPublicUrl}/fonts/inter_ui/Inter-UI-italic.var.woff2") format("woff2"); + } + */ + + /* ROBOTO MONO FONTS */ + /* ROBOTO MONO FONTS */ + /* ROBOTO MONO FONTS */ + /* ROBOTO MONO FONTS */ + /* ROBOTO MONO FONTS */ + @font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 400; + src: local('Roboto Mono Italic'), local('RobotoMono-Italic'), url("#{uiPublicUrl}/fonts/roboto_mono/RobotoMono-Italic.ttf") format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'Roboto Mono'; + font-style: italic; + font-weight: 700; + src: local('Roboto Mono Bold Italic'), local('RobotoMono-BoldItalic'), url("#{uiPublicUrl}/fonts/roboto_mono/RobotoMono-BoldItalic.ttf") format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + src: local('Roboto Mono'), local('RobotoMono-Regular'), url("#{uiPublicUrl}/fonts/roboto_mono/RobotoMono-Regular.ttf") format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + @font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + src: local('Roboto Mono Bold'), local('RobotoMono-Bold'), url("#{uiPublicUrl}/fonts/roboto_mono/RobotoMono-Bold.ttf") format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + } + //- Favicons (generated from http://realfavicongenerator.net/) + link( + rel='apple-touch-icon' sizes='180x180' href=`${uiPublicUrl}/favicons/apple-touch-icon.png` + ) + link( + rel='icon' type='image/png' href=`${uiPublicUrl}/favicons/favicon-32x32.png` sizes='32x32' + ) + link( + rel='icon' type='image/png' href=`${uiPublicUrl}/favicons/favicon-16x16.png` sizes='16x16' + ) + link( + rel='manifest' href=`${uiPublicUrl}/favicons/manifest.json` + ) + link( + rel='mask-icon' href=`${uiPublicUrl}/favicons/safari-pinned-tab.svg` color='#e8488b' + ) + link( + rel='shortcut icon' href=`${uiPublicUrl}/favicons/favicon.ico` + ) + meta( + name='msapplication-config' content=`${uiPublicUrl}/favicons/browserconfig.xml` + ) + meta( + name='theme-color' content='#ffffff' + ) + + style. + .kibanaWelcomeView { + height: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-flex: 1; + -webkit-flex: 1 0 auto; + -ms-flex: 1 0 auto; + flex: 1 0 auto; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + background: #FFFFFF; + } + + .kibanaWelcomeLogo { + width: 100%; + height: 100%; + background-repeat: no-repeat; + background-size: contain; + /* SVG optimized according to http://codepen.io/tigt/post/optimizing-svgs-in-data-uris */ + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OSIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDQ5IDY0Ij4KICA8ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgPHBhdGggZmlsbD0iIzNFQkVCMCIgZD0iTTEuNDE2MzE1NzMsNjQgTDQ4LjY0MDA4MTEsNjQgQzQ4LjY0MDA4MTEsNTEuMTI2NCA0MS4yMzYyNjI0LDM5LjY4NDggMjkuNzQ3NDk0NCwzMi4zNzA0IEwxLjQxNjMxNTczLDY0IFoiLz4KICAgIDxwYXRoIGZpbGw9IiMzN0E1OTUiIGQ9Ik0wLDQxLjYgTDAsNjQgTDMuMDM3NTY4LDY0IEwyOS43NTA2NTYsMzIuMzY1NiBDMjcuOTI1ODQ1MywzMS4yMDMyIDI1Ljk5MjQwNTMsMzAuMTUyIDIzLjk3NTQ2NjcsMjkuMjA4IEwwLDQxLjYgWiIvPgogICAgPHBhdGggZmlsbD0iIzM1MzUzNCIgZD0iTTAsMjAuOCBMMCw1Ny42IEwyMy45Nzk1MiwyOS4yMDMyIEMxNi45MDA3Nzg3LDI1Ljg5NzYgMjQuOTM1Mjk2LDIyLjQgMTYuMjEzMzMzMywyMi40IEwwLDIwLjggWiIvPgogICAgPHBhdGggZmlsbD0iI0U5NDc4QiIgZD0iTTQ4LjY0LDAgTDAsMCBMMCwyNCBDOC43MjE5NjI2NywyNCAxNi45MDA3Nzg3LDI1Ljg5NzYgMjMuOTc5NTIsMjkuMjAzMiBMNDguNjQsMCBaIi8+CiAgPC9nPgo8L3N2Zz4K"); + } + + block head + + body + kbn-csp(data=JSON.stringify({ strictCsp })) + kbn-injected-metadata(data=JSON.stringify(injectedMetadata)) + block content diff --git a/src/core/server/application/views/ui_app.pug b/src/core/server/application/views/ui_app.pug new file mode 100644 index 0000000000000..95b321e09b500 --- /dev/null +++ b/src/core/server/application/views/ui_app.pug @@ -0,0 +1,140 @@ +extends ./chrome + +block content + style. + * { + box-sizing: border-box; + } + + body, html { + width: 100%; + height: 100%; + margin: 0; + background-color: #{darkMode ? '#25262E' : '#F5F7FA'}; + } + .kibanaWelcomeView { + background-color: #{darkMode ? '#25262E' : '#F5F7FA'}; + } + + .kibanaWelcomeTitle { + color: #000; + font-size: 20px; + font-family: Sans-serif; + margin-top: 20px; + animation: fadeIn 1s ease-in-out; + animation-fill-mode: forwards; + opacity: 0; + animation-delay: 1.0s; + } + + .kibanaWelcomeText { + font-size: 14px; + font-family: Sans-serif; + color: #98A2B3; + animation: fadeIn 1s ease-in-out; + animation-fill-mode: forwards; + opacity: 0; + animation-delay: 1.0s; + } + + .kibanaLoaderWrap { + height: 128px; + width: 128px; + position: relative; + margin-top: 40px; + } + + .kibanaLoaderWrap + * { + margin-top: 24px; + } + + .kibanaLoader { + height: 128px; + width: 128px; + margin: 0 auto; + position: relative; + border: 2px solid transparent; + border-top: 2px solid #017D73; + border-radius: 100%; + display: block; + opacity: 0; + animation: rotation .75s .5s infinite linear, fadeIn 1s .5s ease-in-out forwards; + } + + .kibanaWelcomeLogoCircle { + position: absolute; + left: 4px; + top: 4px; + width: 120px; + height: 120px; + padding: 20px; + background-color: #FFF; + border-radius: 50%; + animation: bounceIn .5s ease-in-out; + } + + .kibanaWelcomeLogo { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzOSIgdmlld0JveD0iMCAwIDMwIDM5Ij4gIDxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgIDxwb2x5Z29uIGZpbGw9IiNGMDRFOTgiIHBvaW50cz0iMCAwIDAgMzQuNTQ3IDI5LjkyMiAuMDIiLz4gICAgPHBhdGggZmlsbD0iIzM0Mzc0MSIgZD0iTTAsMTQuNCBMMCwzNC41NDY4IEwxNC4yODcyLDE4LjA2MTIgQzEwLjA0MTYsMTUuNzM4IDUuMTgwNCwxNC40IDAsMTQuNCIvPiAgICA8cGF0aCBmaWxsPSIjMDBCRkIzIiBkPSJNMTcuMzc0MiwxOS45OTY4IEwyLjcyMSwzNi45MDQ4IEwxLjQzMzQsMzguMzg5MiBMMjkuMjYzOCwzOC4zODkyIEMyNy43NjE0LDMwLjgzODggMjMuNDA0MiwyNC4zMjY0IDE3LjM3NDIsMTkuOTk2OCIvPiAgPC9nPjwvc3ZnPg=="); + background-repeat: no-repeat; + background-size: contain; + width: 60px; + height: 60px; + margin: 10px 0px 10px 20px; + } + + @keyframes rotation { + from { + transform: rotate(0deg); + } + to { + transform: rotate(359deg); + } + } + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes bounceIn { + 0% { + opacity: 0; + transform: scale(.1); + } + 80% { + opacity: .5; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } + } + + .kibanaWelcomeView(id="kbn_loading_message", style="display: none;", data-test-subj="kbnLoadingMessage") + .kibanaLoaderWrap + .kibanaLoader + .kibanaWelcomeLogoCircle + .kibanaWelcomeLogo + .kibanaWelcomeText(data-error-message=i18n('common.ui.welcomeErrorMessage', { defaultMessage: 'Kibana did not load properly. Check the server output for more information.' })) + | #{i18n('common.ui.welcomeMessage', { defaultMessage: 'Loading Kibana' })} + + .kibanaWelcomeView(id="kbn_legacy_browser_error", style="display: none;") + .kibanaLoaderWrap + .kibanaWelcomeLogoCircle + .kibanaWelcomeLogo + h2.kibanaWelcomeTitle + | #{i18n('common.ui.legacyBrowserTitle', { defaultMessage: 'Please upgrade your browser' })} + .kibanaWelcomeText + | #{i18n('common.ui.legacyBrowserMessage', { defaultMessage: 'This Kibana installation has strict security requirements enabled that your current browser does not meet.' })} + + script. + // Since this is an unsafe inline script, this code will not run + // in browsers that support content security policy(CSP). This is + // intentional as we check for the existence of __kbnCspNotEnforced__ in + // bootstrap. + window.__kbnCspNotEnforced__ = true; + script(src=bootstrapScriptUrl) diff --git a/src/core/server/config/config.mock.ts b/src/core/server/config/config.mock.ts new file mode 100644 index 0000000000000..e098fa142b9d1 --- /dev/null +++ b/src/core/server/config/config.mock.ts @@ -0,0 +1,34 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Config } from './config'; + +type ConfigMock = jest.Mocked; + +const createConfigMock = (): ConfigMock => ({ + has: jest.fn(), + get: jest.fn(), + set: jest.fn(), + getFlattenedPaths: jest.fn(), + toRaw: jest.fn(), +}); + +export const configMock = { + create: createConfigMock, +}; diff --git a/src/core/server/csp/index.test.ts b/src/core/server/csp/index.test.ts new file mode 100644 index 0000000000000..207b7be9df26a --- /dev/null +++ b/src/core/server/csp/index.test.ts @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createCSPRuleString, + DEFAULT_CSP_RULES, + DEFAULT_CSP_STRICT, + DEFAULT_CSP_WARN_LEGACY_BROWSERS, +} from './'; + +// CSP rules aren't strictly additive, so any change can potentially expand or +// restrict the policy in a way we consider a breaking change. For that reason, +// we test the default rules exactly so any change to those rules gets flagged +// for manual review. In otherwords, this test is intentionally fragile to draw +// extra attention if defaults are modified in any way. +// +// A test failure here does not necessarily mean this change cannot be made, +// but any change here should undergo sufficient scrutiny by the Kibana +// security team. +// +// The tests use inline snapshots to make it as easy as possible to identify +// the nature of a change in defaults during a PR review. +test('default CSP rules', () => { + expect(DEFAULT_CSP_RULES).toMatchInlineSnapshot(` + Array [ + "script-src 'unsafe-eval' 'self'", + "worker-src blob:", + "child-src blob:", + "style-src 'unsafe-inline' 'self'", + ] + `); +}); + +test('CSP strict mode defaults to disabled', () => { + expect(DEFAULT_CSP_STRICT).toBe(true); +}); + +test('CSP legacy browser warning defaults to enabled', () => { + expect(DEFAULT_CSP_WARN_LEGACY_BROWSERS).toBe(true); +}); + +test('createCSPRuleString() converts an array of rules into a CSP header string', () => { + const csp = createCSPRuleString([`string-src 'self'`, 'worker-src blob:', 'img-src data: blob:']); + + expect(csp).toMatchInlineSnapshot(`"string-src 'self'; worker-src blob:; img-src data: blob:"`); +}); diff --git a/src/core/server/csp/index.ts b/src/core/server/csp/index.ts new file mode 100644 index 0000000000000..95d724ec1b44a --- /dev/null +++ b/src/core/server/csp/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const DEFAULT_CSP_RULES = Object.freeze([ + `script-src 'unsafe-eval' 'self'`, + 'worker-src blob:', + 'child-src blob:', + `style-src 'unsafe-inline' 'self'`, +]); + +export const DEFAULT_CSP_STRICT = true; + +export const DEFAULT_CSP_WARN_LEGACY_BROWSERS = true; + +export const createCspRuleString = (rules: string[]) => rules.join('; '); diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index c4a61aaf83ac7..71dfd10fee455 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -19,6 +19,7 @@ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; import { Env } from '../config'; +import { DEFAULT_CSP_RULES, DEFAULT_CSP_STRICT, DEFAULT_CSP_WARN_LEGACY_BROWSERS } from '../csp'; import { SslConfig, sslSchema } from './ssl_config'; const validBasePathRegex = /(^$|^\/.*[^\/]$)/; @@ -38,7 +39,20 @@ export const config = { validate: match(validBasePathRegex, "must start with a slash, don't end with one"), }) ), - defaultRoute: schema.maybe(schema.string()), + csp: schema.object( + { + rules: schema.arrayOf(schema.string()), + strict: schema.boolean(), + warnLegacyBrowsers: schema.boolean(), + }, + { + defaultValue: { + rules: [...DEFAULT_CSP_RULES], + strict: DEFAULT_CSP_STRICT, + warnLegacyBrowsers: DEFAULT_CSP_WARN_LEGACY_BROWSERS, + }, + } + ), cors: schema.conditional( schema.contextRef('dev'), true, @@ -54,6 +68,7 @@ export const config = { ), schema.boolean({ defaultValue: false }) ), + defaultRoute: schema.maybe(schema.string()), host: schema.string({ defaultValue: 'localhost', hostname: true, @@ -102,6 +117,7 @@ export class HttpConfig { public keepaliveTimeout: number; public socketTimeout: number; public port: number; + public csp: HttpConfigType['csp']; public cors: boolean | { origin: string[] }; public maxPayload: ByteSizeValue; public basePath?: string; @@ -117,6 +133,7 @@ export class HttpConfig { this.autoListen = rawConfig.autoListen; this.host = rawConfig.host; this.port = rawConfig.port; + this.csp = rawConfig.csp; this.cors = rawConfig.cors; this.maxPayload = rawConfig.maxPayload; this.basePath = rawConfig.basePath; diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index da97ab535516c..c84b33e219cdd 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -50,6 +50,7 @@ export interface HttpServerSetup { registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; isTlsEnabled: HttpServiceSetup['isTlsEnabled']; + csp: HttpServiceSetup['csp']; auth: { get: GetAuthState; isAuthenticated: IsAuthenticated; @@ -111,6 +112,7 @@ export class HttpServer { getAuthHeaders: this.authRequestHeaders.get, }, isTlsEnabled: config.ssl.enabled, + csp: config.csp, // Return server instance with the connection options so that we can properly // bridge core and the "legacy" Kibana internally. Once this bridge isn't // needed anymore we shouldn't return the instance from this method. diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 00c9aedc42cfb..e391cf9636138 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -18,6 +18,7 @@ */ import { Server } from 'hapi'; +import { DEFAULT_CSP_RULES, DEFAULT_CSP_STRICT, DEFAULT_CSP_WARN_LEGACY_BROWSERS } from '../csp'; import { InternalHttpServiceSetup } from './types'; import { HttpService } from './http_service'; import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; @@ -26,13 +27,14 @@ import { sessionStorageMock } from './cookie_session_storage.mocks'; import { IRouter } from './router'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; +type BasePathMocked = jest.Mocked; type ServiceSetupMockType = jest.Mocked & { - basePath: jest.Mocked; + basePath: BasePathMocked; }; -const createBasePathMock = (): jest.Mocked => ({ - serverBasePath: '/mock-server-basepath', - get: jest.fn(), +const createBasePathMock = (serverBasePath = '/mock-server-basepath'): BasePathMocked => ({ + serverBasePath, + get: jest.fn().mockReturnValue(serverBasePath), set: jest.fn(), prepend: jest.fn(), remove: jest.fn(), @@ -51,9 +53,16 @@ const createSetupContractMock = () => { const setupContract: ServiceSetupMockType = { // we can mock other hapi server methods when we need it server: ({ + name: 'http-server-test', + version: 'kibana', route: jest.fn(), start: jest.fn(), stop: jest.fn(), + getUiAppById: jest.fn(), // ! legacy + getInjectedUiAppVars: jest.fn().mockResolvedValue({}), // ! legacy + getUiNavLinks: jest.fn().mockReturnValue([]), // ! legacy + getCapabilitiesModifiers: jest.fn().mockReturnValue([]), // ! legacy + getDefaultCapabilities: jest.fn().mockReturnValue({}), // ! legacy } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), registerOnPreAuth: jest.fn(), @@ -68,6 +77,11 @@ const createSetupContractMock = () => { getAuthHeaders: jest.fn(), }, isTlsEnabled: false, + csp: { + rules: [...DEFAULT_CSP_RULES], + strict: DEFAULT_CSP_STRICT, + warnLegacyBrowsers: DEFAULT_CSP_WARN_LEGACY_BROWSERS, + }, config: {}, }; setupContract.createCookieSessionStorageFactory.mockResolvedValue( diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 2c3dfedd1d181..22aefc9e6964e 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -174,6 +174,15 @@ export interface HttpServiceSetup { */ isTlsEnabled: boolean; + /** + * Config settings related to browser Content Security Policy. + */ + csp: { + rules: string[]; + strict: boolean; + warnLegacyBrowsers: boolean; + }; + /** * Provides ability to declare a handler function for a particular path and HTTP request method. * diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 987e4e64f9d5b..4c26ae035b8d4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -41,6 +41,7 @@ import { ElasticsearchServiceSetup, IScopedClusterClient } from './elasticsearch'; import { HttpServiceSetup } from './http'; +import { IApplicationContextProvider } from './application'; import { PluginsServiceSetup, PluginsServiceStart, PluginOpaqueId } from './plugins'; import { ContextSetup } from './context'; import { IUiSettingsClient, UiSettingsServiceSetup } from './ui_settings'; @@ -118,6 +119,11 @@ export { SessionStorageCookieOptions, SessionStorageFactory, } from './http'; +export { + IApplicationContextProvider, + ApplicationServiceSetup, + CapabilitiesModifier, +} from './application'; export { Logger, LoggerFactory, LogMeta, LogRecord, LogLevel } from './logging'; export { @@ -195,6 +201,8 @@ export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; * Plugin specific context passed to a route handler. * * Provides the following clients: + * - {@link ApplicationClient | application} - Application client + * which uses the credentials of the incoming request * - {@link SavedObjectsClient | savedObjects.client} - Saved Objects client * which uses the credentials of the incoming request * - {@link ScopedClusterClient | elasticsearch.dataClient} - Elasticsearch @@ -206,6 +214,7 @@ export { LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy'; */ export interface RequestHandlerContext { core: { + application: IApplicationContextProvider; savedObjects: { client: SavedObjectsClientContract; }; diff --git a/src/core/server/legacy/index.ts b/src/core/server/legacy/index.ts index 84aec7077d407..3fd0e296ad4c6 100644 --- a/src/core/server/legacy/index.ts +++ b/src/core/server/legacy/index.ts @@ -20,4 +20,9 @@ /** @internal */ export { LegacyObjectToConfigAdapter } from './config/legacy_object_to_config_adapter'; /** @internal */ -export { LegacyService, LegacyServiceSetupDeps, LegacyServiceStartDeps } from './legacy_service'; +export { + LegacyService, + LegacyServiceSetup, + LegacyServiceSetupDeps, + LegacyServiceStartDeps, +} from './legacy_service'; diff --git a/src/core/server/legacy/legacy_service.mock.ts b/src/core/server/legacy/legacy_service.mock.ts new file mode 100644 index 0000000000000..55251c01fa1f8 --- /dev/null +++ b/src/core/server/legacy/legacy_service.mock.ts @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { configMock } from '../config/config.mock'; +import { LegacyService, LegacyServiceSetup } from './legacy_service'; + +type LegacyServiceMock = jest.Mocked & { legacyId: symbol }>; + +const createSetupContractMock = (): LegacyServiceSetup => ({ + pluginSpecs: [], + uiExports: {} as any, + pluginExtendedConfig: configMock.create(), +}); +const createLegacyServiceMock = (): LegacyServiceMock => ({ + legacyId: Symbol(), + setup: jest.fn().mockReturnValue(createSetupContractMock()), + start: jest.fn(), + stop: jest.fn(), +}); + +export const legacyServiceMock = { + create: createLegacyServiceMock, + createSetupContract: createSetupContractMock, +}; diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index e86e6cde6e927..69f70359b3b44 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -248,6 +248,7 @@ export class LegacyService implements CoreService { registerOnPostAuth: setupDeps.core.http.registerOnPostAuth, basePath: setupDeps.core.http.basePath, isTlsEnabled: setupDeps.core.http.isTlsEnabled, + csp: setupDeps.core.http.csp, }, uiSettings: { register: setupDeps.core.uiSettings.register, diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index b51d5302e3274..2f43ba59eb21b 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -75,6 +75,7 @@ function createCoreSetupMock() { registerOnPostAuth: httpService.registerOnPostAuth, basePath: httpService.basePath, isTlsEnabled: httpService.isTlsEnabled, + csp: httpService.csp, createRouter: jest.fn(), registerRouteHandlerContext: jest.fn(), }; diff --git a/src/core/server/plugins/plugins_service.mock.ts b/src/core/server/plugins/plugins_service.mock.ts index e3be8fbb98309..afd0f0ca39cce 100644 --- a/src/core/server/plugins/plugins_service.mock.ts +++ b/src/core/server/plugins/plugins_service.mock.ts @@ -17,28 +17,28 @@ * under the License. */ -import { PluginsService } from './plugins_service'; +import { PluginsService, PluginsServiceSetup } from './plugins_service'; -type ServiceContract = PublicMethodsOf; -const createServiceMock = () => { - const mocked: jest.Mocked = { - discover: jest.fn(), - setup: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - mocked.setup.mockResolvedValue({ - contracts: new Map(), - uiPlugins: { - public: new Map(), - internal: new Map(), - }, - uiPluginConfigs: new Map(), - }); - mocked.start.mockResolvedValue({ contracts: new Map() }); - return mocked; -}; +type PluginsServiceMock = jest.Mocked>; + +const createSetupContractMock = (): PluginsServiceSetup => ({ + contracts: new Map(), + uiPlugins: { + public: new Map(), + internal: new Map(), + }, + uiPluginConfigs: new Map(), +}); +const createStartContractMock = () => ({ contracts: new Map() }); +const createServiceMock = (): PluginsServiceMock => ({ + discover: jest.fn(), + setup: jest.fn().mockResolvedValue(createSetupContractMock()), + start: jest.fn().mockResolvedValue(createStartContractMock()), + stop: jest.fn(), +}); export const pluginServiceMock = { create: createServiceMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, }; diff --git a/src/core/server/server.test.mocks.ts b/src/core/server/server.test.mocks.ts index f8eb5e32f4c5a..f5f336207e10b 100644 --- a/src/core/server/server.test.mocks.ts +++ b/src/core/server/server.test.mocks.ts @@ -35,13 +35,9 @@ jest.doMock('./elasticsearch/elasticsearch_service', () => ({ ElasticsearchService: jest.fn(() => mockElasticsearchService), })); -export const mockLegacyService = { - legacyId: Symbol(), - setup: jest.fn().mockReturnValue({ uiExports: {} }), - start: jest.fn(), - stop: jest.fn(), -}; -jest.mock('./legacy/legacy_service', () => ({ +import { legacyServiceMock } from './legacy/legacy_service.mock'; +export const mockLegacyService = legacyServiceMock.create(); +jest.doMock('./legacy/legacy_service', () => ({ LegacyService: jest.fn(() => mockLegacyService), })); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 6c38de03f0f2d..64c910934fe6f 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -23,6 +23,7 @@ import { Type } from '@kbn/config-schema'; import { ConfigService, Env, Config, ConfigPath } from './config'; import { ElasticsearchService } from './elasticsearch'; import { HttpService, InternalHttpServiceSetup } from './http'; +import { ApplicationService, ApplicationServiceSetup } from './application'; import { LegacyService } from './legacy'; import { Logger, LoggerFactory } from './logging'; import { UiSettingsService } from './ui_settings'; @@ -49,6 +50,7 @@ export class Server { private readonly context: ContextService; private readonly elasticsearch: ElasticsearchService; private readonly http: HttpService; + private readonly application: ApplicationService; private readonly legacy: LegacyService; private readonly log: Logger; private readonly plugins: PluginsService; @@ -66,6 +68,7 @@ export class Server { const core = { coreId, configService: this.configService, env, logger }; this.context = new ContextService(core); this.http = new HttpService(core); + this.application = new ApplicationService(core); this.plugins = new PluginsService(core); this.legacy = new LegacyService(core); this.elasticsearch = new ElasticsearchService(core); @@ -117,12 +120,18 @@ export class Server { plugins: mapToObject(pluginsSetup.contracts), }); + const applicationSetup = await this.application.setup({ + http: httpSetup, + plugins: pluginsSetup, + legacy: legacySetup, + }); + const savedObjectsSetup = await this.savedObjects.setup({ elasticsearch: elasticsearchServiceSetup, legacy: legacySetup, }); - this.registerCoreContext(coreSetup, savedObjectsSetup); + this.registerCoreContext(coreSetup, savedObjectsSetup, applicationSetup); return coreSetup; } @@ -143,6 +152,7 @@ export class Server { }); await this.http.start(); + await this.application.start(); return coreStart; } @@ -155,6 +165,7 @@ export class Server { await this.savedObjects.stop(); await this.elasticsearch.stop(); await this.http.stop(); + await this.application.stop(); } private registerDefaultRoute(httpSetup: InternalHttpServiceSetup) { @@ -166,17 +177,25 @@ export class Server { private registerCoreContext( coreSetup: InternalCoreSetup, - savedObjects: SavedObjectsServiceSetup + savedObjects: SavedObjectsServiceSetup, + application: ApplicationServiceSetup ) { coreSetup.http.registerRouteHandlerContext( coreId, 'core', - async (context, req): Promise => { + async (context, req, res): Promise => { const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise(); const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise(); const savedObjectsClient = savedObjects.clientProvider.getClient(req); + const uiSettingsClient = coreSetup.uiSettings.asScopedToClient(savedObjectsClient); + const applicationProvider = await application.getCoreContextProvider({ + request: req, + response: res, + uiSettingsClient, + }); return { + application: applicationProvider, savedObjects: { // Note: the client provider doesn't support new ES clients // emitted from adminClient$ @@ -187,7 +206,7 @@ export class Server { dataClient: dataClient.asScoped(req), }, uiSettings: { - client: coreSetup.uiSettings.asScopedToClient(savedObjectsClient), + client: uiSettingsClient, }, }; } diff --git a/src/core/server/ui_settings/ui_settings_service.mock.ts b/src/core/server/ui_settings/ui_settings_service.mock.ts index bb21109a2f967..b4d3feccf59f8 100644 --- a/src/core/server/ui_settings/ui_settings_service.mock.ts +++ b/src/core/server/ui_settings/ui_settings_service.mock.ts @@ -32,6 +32,7 @@ const createClientMock = () => { isOverridden: jest.fn(), }; mocked.get.mockResolvedValue(false); + mocked.getAll.mockResolvedValue({}); return mocked; }; diff --git a/src/legacy/server/capabilities/capabilities_mixin.test.ts b/src/legacy/server/capabilities/capabilities_mixin.test.ts index 9b6827e1bb380..552d06fd176c4 100644 --- a/src/legacy/server/capabilities/capabilities_mixin.test.ts +++ b/src/legacy/server/capabilities/capabilities_mixin.test.ts @@ -86,6 +86,32 @@ describe('capabilitiesMixin', () => { expect(mockRegisterCapabilitiesRoute.mock.calls[0][2]).toEqual([mockModifier1, mockModifier2]); }); + it('exposes server#getCapabilitiesModifiers for getting modifiers from the server', async () => { + const kbnServer = getKbnServer(); + await capabilitiesMixin(kbnServer, server); + const mockModifier1 = jest.fn(); + const mockModifier2 = jest.fn(); + server.registerCapabilitiesModifier(mockModifier1); + server.registerCapabilitiesModifier(mockModifier2); + + expect(server.getCapabilitiesModifiers()).toEqual([mockModifier1, mockModifier2]); + }); + + it('exposes server#getDefaultCapabilities for getting default capabilities from the server', async () => { + const kbnServer = getKbnServer(); + await capabilitiesMixin(kbnServer, server); + + expect(server.getDefaultCapabilities()).toMatchInlineSnapshot(` + Object { + "catalogue": Object {}, + "management": Object {}, + "navLinks": Object {}, + } + `); + }); + + // TODO need to also add integration tests for the application service + it('exposes request#getCapabilities for retrieving legacy capabilities', async () => { const kbnServer = getKbnServer(); jest.spyOn(server, 'decorate'); diff --git a/src/legacy/server/capabilities/capabilities_mixin.ts b/src/legacy/server/capabilities/capabilities_mixin.ts index b41dfe42c40b2..b7478c212c59c 100644 --- a/src/legacy/server/capabilities/capabilities_mixin.ts +++ b/src/legacy/server/capabilities/capabilities_mixin.ts @@ -17,30 +17,30 @@ * under the License. */ -import { Server, Request } from 'hapi'; +import { Server } from 'hapi'; import { Capabilities } from '../../../core/public'; +import { KibanaRequest, CapabilitiesModifier } from '../../../core/server'; +import { deepFreeze } from '../../../core/utils'; import KbnServer from '../kbn_server'; import { registerCapabilitiesRoute } from './capabilities_route'; import { mergeCapabilities } from './merge_capabilities'; import { resolveCapabilities } from './resolve_capabilities'; -export type CapabilitiesModifier = ( - request: Request, - uiCapabilities: Capabilities -) => Capabilities | Promise; - export async function capabilitiesMixin(kbnServer: KbnServer, server: Server) { const modifiers: CapabilitiesModifier[] = []; + let defaultCapabilities: Capabilities; server.decorate('server', 'registerCapabilitiesModifier', (provider: CapabilitiesModifier) => { modifiers.push(provider); }); + server.decorate('server', 'getDefaultCapabilities', () => deepFreeze(defaultCapabilities)); + server.decorate('server', 'getCapabilitiesModifiers', () => deepFreeze(modifiers)); // Some plugin capabilities are derived from data provided by other plugins, // so we need to wait until after all plugins have been init'd to fetch uiCapabilities. kbnServer.afterPluginsInit(async () => { - const defaultCapabilities = mergeCapabilities( + defaultCapabilities = mergeCapabilities( ...(await Promise.all( kbnServer.pluginSpecs .map(spec => spec.getUiCapabilitiesProvider()) @@ -59,7 +59,9 @@ export async function capabilitiesMixin(kbnServer: KbnServer, server: Server) { {} as Record ); - return resolveCapabilities(this, modifiers, defaultCapabilities, { navLinks }); + return resolveCapabilities(KibanaRequest.from(this), modifiers, defaultCapabilities, { + navLinks, + }); }); registerCapabilitiesRoute(server, defaultCapabilities, modifiers); diff --git a/src/legacy/server/capabilities/capabilities_route.ts b/src/legacy/server/capabilities/capabilities_route.ts index 5564fbb295a62..18e360a1b4193 100644 --- a/src/legacy/server/capabilities/capabilities_route.ts +++ b/src/legacy/server/capabilities/capabilities_route.ts @@ -21,7 +21,7 @@ import Joi from 'joi'; import { Server } from 'hapi'; import { Capabilities } from '../../../core/public'; -import { CapabilitiesModifier } from './capabilities_mixin'; +import { CapabilitiesModifier, KibanaRequest } from '../../../core/server'; import { resolveCapabilities } from './resolve_capabilities'; export const registerCapabilitiesRoute = ( @@ -43,7 +43,7 @@ export const registerCapabilitiesRoute = ( const { capabilities } = request.payload as { capabilities: Capabilities }; return { capabilities: await resolveCapabilities( - request, + KibanaRequest.from(request), modifiers, defaultCapabilities, capabilities diff --git a/src/legacy/server/capabilities/index.ts b/src/legacy/server/capabilities/index.ts index 09461a40c008a..8c5dea1226f2b 100644 --- a/src/legacy/server/capabilities/index.ts +++ b/src/legacy/server/capabilities/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { CapabilitiesModifier, capabilitiesMixin } from './capabilities_mixin'; +export { capabilitiesMixin } from './capabilities_mixin'; diff --git a/src/legacy/server/capabilities/resolve_capabilities.ts b/src/legacy/server/capabilities/resolve_capabilities.ts index 0df4932099b54..ede4940cac177 100644 --- a/src/legacy/server/capabilities/resolve_capabilities.ts +++ b/src/legacy/server/capabilities/resolve_capabilities.ts @@ -17,14 +17,12 @@ * under the License. */ -import { Request } from 'hapi'; - import { Capabilities } from '../../../core/public'; +import { KibanaRequest, CapabilitiesModifier } from '../../../core/server'; import { mergeCapabilities } from './merge_capabilities'; -import { CapabilitiesModifier } from './capabilities_mixin'; export const resolveCapabilities = ( - request: Request, + request: KibanaRequest, modifiers: CapabilitiesModifier[], ...capabilities: Array> ) => diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 7399f2d08508f..137e2f08eacd5 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -20,8 +20,10 @@ import { ResponseObject, Server } from 'hapi'; import { UnwrapPromise } from '@kbn/utility-types'; -import { SavedObjectsClientProviderOptions, CoreSetup } from 'src/core/server'; import { + SavedObjectsClientProviderOptions, + CoreSetup, + CapabilitiesModifier, ConfigService, ElasticsearchServiceSetup, EnvironmentMode, @@ -30,16 +32,16 @@ import { SavedObjectsLegacyService, IUiSettingsClient, PackageInfo, + LegacyServiceSetupDeps, + LegacyServiceStartDeps, } from '../../core/server'; -import { LegacyServiceSetupDeps, LegacyServiceStartDeps } from '../../core/server/'; // Disable lint errors for imports from src/core/server/saved_objects until SavedObjects migration is complete // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { SavedObjectsManagement } from '../../core/server/saved_objects/management'; import { ApmOssPlugin } from '../core_plugins/apm_oss'; import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/elasticsearch'; -import { CapabilitiesModifier } from './capabilities'; import { IndexPatternsServiceFactory } from './index_patterns'; import { Capabilities } from '../../core/public'; import { UiSettingsServiceFactoryOptions } from '../../legacy/ui/ui_settings/ui_settings_service_factory'; @@ -69,13 +71,16 @@ declare module 'hapi' { savedObjects: SavedObjectsLegacyService; usage: { collectorSet: any }; injectUiAppVars: (pluginName: string, getAppVars: () => { [key: string]: any }) => void; + getUiAppById(appId: string): UiApp; getHiddenUiAppById(appId: string): UiApp; registerCapabilitiesModifier: (provider: CapabilitiesModifier) => void; + getCapabilitiesModifiers: () => Readonly; + getDefaultCapabilities: () => Readonly; addScopedTutorialContextFactory: ( scopedTutorialContextFactory: (...args: any[]) => any ) => void; savedObjectsManagement(): SavedObjectsManagement; - getInjectedUiAppVars: (pluginName: string) => { [key: string]: any }; + getInjectedUiAppVars: (pluginName: string) => Promise>; getUiNavLinks(): Array<{ _id: string }>; addMemoizedFactoryToRequest: ( name: string, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 4b3997fe74f1b..894d1342c1629 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -15,12 +15,12 @@ import { RecursiveReadonly, SavedObjectsLegacyService, LegacyRequest, + CapabilitiesModifier, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; import { SpacesPluginSetup } from '../../spaces/server'; import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; -import { CapabilitiesModifier } from '../../../../src/legacy/server/capabilities'; import { Authentication, setupAuthentication } from './authentication'; import { Authorization, setupAuthorization } from './authorization'; @@ -194,7 +194,7 @@ export class Plugin { }); legacyAPI.capabilities.registerCapabilitiesModifier((request, capabilities) => - authz.disableUnauthorizedCapabilities(KibanaRequest.from(request), capabilities) + authz.disableUnauthorizedCapabilities(request, capabilities) ); }, diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 6511a5dc3f31b..1a87b24c61313 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -6,13 +6,12 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { CapabilitiesModifier } from 'src/legacy/server/capabilities'; import { SavedObjectsLegacyService, CoreSetup, - KibanaRequest, Logger, PluginInitializerContext, + CapabilitiesModifier, } from '../../../../src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { PluginSetupContract as SecurityPluginSetup } from '../../security/server'; @@ -182,7 +181,7 @@ export class Plugin { ); legacyAPI.capabilities.registerCapabilitiesModifier(async (request, uiCapabilities) => { try { - const activeSpace = await spacesService.getActiveSpace(KibanaRequest.from(request)); + const activeSpace = await spacesService.getActiveSpace(request); const features = featuresSetup.getFeatures(); return toggleUICapabilities(features, uiCapabilities, activeSpace); } catch (e) { diff --git a/yarn.lock b/yarn.lock index 68b9a74829281..c9c0544609660 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3715,6 +3715,11 @@ resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-3.0.1.tgz#dd770a2abce3adbcce3bd1ed892ce2f5f17fbc86" integrity sha512-ODOjqxmaNs0Zkij+BJovsNJRSX7BJrr681o8ZnNTNIcTermvVFzLpz/XFtfg3vNrlPVTJY1l4e9h2LvHoxC1lg== +"@types/pug@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.4.tgz#8772fcd0418e3cd2cc171555d73007415051f4b2" + integrity sha1-h3L80EGOPNLMFxVV1zAHQVBR9LI= + "@types/puppeteer@^1.20.1": version "1.20.1" resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-1.20.1.tgz#0aba5ae3d290daa91cd3ba9f66ba5e9fba3499cc" @@ -29307,8 +29312,6 @@ wbuf@^1.1.0: version "1.7.2" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.2.tgz#d697b99f1f59512df2751be42769c1580b5801fe" integrity sha1-1pe5nx9ZUS3ydRvkJ2nBWAtYAf4= - dependencies: - minimalistic-assert "^1.0.0" wbuf@^1.7.3: version "1.7.3"