From 480886c861f0be318a549df2e9aacdaf5da5d0c9 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet <pierre.gayvallet@elastic.co> Date: Mon, 6 Jan 2020 17:36:21 +0100 Subject: [PATCH 1/3] [7.x] migrate xsrf / version-check / custom-headers handlers to NP (#53684) (#54016) * migrate xsrf / version-check / custom-headers handlers to NP (#53684) * migrate xsrf / version-check / custom-headers handlers to NP * export lifecycleMock to be used by plugins * move toolkit mock to http_server mock * remove legacy config tests on xsrf * fix integration_test http configuration * remove direct HAPI usages from integration tests * nits and comments * add custom headers test in case of server returning error * resolve merge conflicts * restore `server.name` to legacy config * update snapshots --- .../__snapshots__/http_config.test.ts.snap | 6 + .../http/cookie_session_storage.test.ts | 4 + src/core/server/http/http_config.test.ts | 23 ++ src/core/server/http/http_config.ts | 19 ++ src/core/server/http/http_server.mocks.ts | 13 + src/core/server/http/http_server.ts | 6 + .../server/http/http_service.test.mocks.ts | 4 + src/core/server/http/http_service.ts | 19 +- .../lifecycle_handlers.test.ts | 241 ++++++++++++++++ .../server/http/lifecycle_handlers.test.ts | 269 ++++++++++++++++++ src/core/server/http/lifecycle_handlers.ts | 93 ++++++ src/core/server/http/test_utils.ts | 4 + ...gacy_object_to_config_adapter.test.ts.snap | 16 ++ .../legacy_object_to_config_adapter.test.ts | 12 + .../config/legacy_object_to_config_adapter.ts | 5 +- src/legacy/server/config/schema.js | 15 +- src/legacy/server/config/schema.test.js | 56 ---- src/legacy/server/http/index.js | 28 -- .../integration_tests/version_check.test.js | 64 ----- .../http/integration_tests/xsrf.test.js | 145 ---------- src/legacy/server/http/version_check.js | 39 --- src/legacy/server/http/xsrf.js | 47 --- src/test_utils/kbn_server.ts | 4 +- 23 files changed, 730 insertions(+), 402 deletions(-) create mode 100644 src/core/server/http/integration_tests/lifecycle_handlers.test.ts create mode 100644 src/core/server/http/lifecycle_handlers.test.ts create mode 100644 src/core/server/http/lifecycle_handlers.ts delete mode 100644 src/legacy/server/http/integration_tests/version_check.test.js delete mode 100644 src/legacy/server/http/integration_tests/xsrf.test.js delete mode 100644 src/legacy/server/http/version_check.js delete mode 100644 src/legacy/server/http/xsrf.js diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index 6c690f9da70c3..8856eb95ba722 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -31,11 +31,13 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object {}, "host": "localhost", "keepaliveTimeout": 120000, "maxPayload": ByteSizeValue { "valueInBytes": 1048576, }, + "name": "kibana-hostname", "port": 5601, "rewriteBasePath": false, "socketTimeout": 120000, @@ -70,6 +72,10 @@ Object { "TLSv1.2", ], }, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; diff --git a/src/core/server/http/cookie_session_storage.test.ts b/src/core/server/http/cookie_session_storage.test.ts index 0e4f3972fe9dc..4ce422e1f65c4 100644 --- a/src/core/server/http/cookie_session_storage.test.ts +++ b/src/core/server/http/cookie_session_storage.test.ts @@ -58,6 +58,10 @@ configService.atPath.mockReturnValue( verificationMode: 'none', }, compression: { enabled: true }, + xsrf: { + disableProtection: true, + whitelist: [], + }, } as any) ); diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index 082b85ad68add..3dc5fa48bc366 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -23,6 +23,11 @@ import { config, HttpConfig } from '.'; const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost']; const invalidHostname = 'asdf$%^'; +jest.mock('os', () => ({ + ...jest.requireActual('os'), + hostname: () => 'kibana-hostname', +})); + test('has defaults for config', () => { const httpSchema = config.schema; const obj = {}; @@ -84,6 +89,24 @@ test('accepts only valid uuids for server.uuid', () => { ); }); +test('uses os.hostname() as default for server.name', () => { + const httpSchema = config.schema; + const validated = httpSchema.validate({}); + expect(validated.name).toEqual('kibana-hostname'); +}); + +test('throws if xsrf.whitelist element does not start with a slash', () => { + const httpSchema = config.schema; + const obj = { + xsrf: { + whitelist: ['/valid-path', 'invalid-path'], + }, + }; + expect(() => httpSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"[xsrf.whitelist.1]: must start with a slash"` + ); +}); + describe('with TLS', () => { test('throws if TLS is enabled but `key` is not specified', () => { const httpSchema = config.schema; diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 5749eb383f8b9..7d6a6ddb857b1 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -18,6 +18,8 @@ */ import { ByteSizeValue, schema, TypeOf } from '@kbn/config-schema'; +import { hostname } from 'os'; + import { CspConfigType, CspConfig, ICspConfig } from '../csp'; import { SslConfig, sslSchema } from './ssl_config'; @@ -33,6 +35,7 @@ export const config = { path: 'server', schema: schema.object( { + name: schema.string({ defaultValue: () => hostname() }), autoListen: schema.boolean({ defaultValue: true }), basePath: schema.maybe( schema.string({ @@ -63,6 +66,9 @@ export const config = { ), schema.boolean({ defaultValue: false }) ), + customResponseHeaders: schema.recordOf(schema.string(), schema.string(), { + defaultValue: {}, + }), host: schema.string({ defaultValue: 'localhost', hostname: true, @@ -97,6 +103,13 @@ export const config = { validate: match(uuidRegexp, 'must be a valid uuid'), }) ), + xsrf: schema.object({ + disableProtection: schema.boolean({ defaultValue: false }), + whitelist: schema.arrayOf( + schema.string({ validate: match(/^\//, 'must start with a slash') }), + { defaultValue: [] } + ), + }), }, { validate: rawConfig => { @@ -125,12 +138,14 @@ export const config = { export type HttpConfigType = TypeOf<typeof config.schema>; export class HttpConfig { + public name: string; public autoListen: boolean; public host: string; public keepaliveTimeout: number; public socketTimeout: number; public port: number; public cors: boolean | { origin: string[] }; + public customResponseHeaders: Record<string, string>; public maxPayload: ByteSizeValue; public basePath?: string; public rewriteBasePath: boolean; @@ -138,6 +153,7 @@ export class HttpConfig { public ssl: SslConfig; public compression: { enabled: boolean; referrerWhitelist?: string[] }; public csp: ICspConfig; + public xsrf: { disableProtection: boolean; whitelist: string[] }; /** * @internal @@ -147,7 +163,9 @@ export class HttpConfig { this.host = rawHttpConfig.host; this.port = rawHttpConfig.port; this.cors = rawHttpConfig.cors; + this.customResponseHeaders = rawHttpConfig.customResponseHeaders; this.maxPayload = rawHttpConfig.maxPayload; + this.name = rawHttpConfig.name; this.basePath = rawHttpConfig.basePath; this.keepaliveTimeout = rawHttpConfig.keepaliveTimeout; this.socketTimeout = rawHttpConfig.socketTimeout; @@ -156,5 +174,6 @@ export class HttpConfig { this.defaultRoute = rawHttpConfig.defaultRoute; this.compression = rawHttpConfig.compression; this.csp = new CspConfig(rawCspConfig); + this.xsrf = rawHttpConfig.xsrf; } } diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index ba742292e9e83..230a229b36888 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -30,6 +30,9 @@ import { RouteMethod, KibanaResponseFactory, } from './router'; +import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; +import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; +import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; interface RequestFixtureOptions { headers?: Record<string, string>; @@ -137,9 +140,19 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked<LifecycleResponseFact customError: jest.fn(), }); +type ToolkitMock = jest.Mocked<OnPreResponseToolkit & OnPostAuthToolkit & OnPreAuthToolkit>; + +const createToolkitMock = (): ToolkitMock => { + return { + next: jest.fn(), + rewriteUrl: jest.fn(), + }; +}; + export const httpServerMock = { createKibanaRequest: createKibanaRequestMock, createRawRequest: createRawRequestMock, createResponseFactory: createResponseFactoryMock, createLifecycleResponseFactory: createLifecycleResponseFactoryMock, + createToolkit: createToolkitMock, }; diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 994a6cced8914..6b978b71c6f2b 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -60,6 +60,12 @@ export interface HttpServerSetup { }; } +/** @internal */ +export type LifecycleRegistrar = Pick< + HttpServerSetup, + 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse' +>; + export class HttpServer { private server?: Server; private config?: HttpConfig; diff --git a/src/core/server/http/http_service.test.mocks.ts b/src/core/server/http/http_service.test.mocks.ts index c147944f2b7d8..e18008d3b405d 100644 --- a/src/core/server/http/http_service.test.mocks.ts +++ b/src/core/server/http/http_service.test.mocks.ts @@ -27,3 +27,7 @@ jest.mock('./http_server', () => { HttpServer: mockHttpServer, }; }); + +jest.mock('./lifecycle_handlers', () => ({ + registerCoreHandlers: jest.fn(), +})); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index e038443d5c83f..fb12ed80b8e2f 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -21,11 +21,10 @@ import { Observable, Subscription, combineLatest } from 'rxjs'; import { first, map } from 'rxjs/operators'; import { Server } from 'hapi'; -import { LoggerFactory } from '../logging'; import { CoreService } from '../../types'; - -import { Logger } from '../logging'; +import { Logger, LoggerFactory } from '../logging'; import { ContextSetup } from '../context'; +import { Env } from '../config'; import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; @@ -43,6 +42,7 @@ import { } from './types'; import { RequestHandlerContext } from '../../server'; +import { registerCoreHandlers } from './lifecycle_handlers'; interface SetupDeps { context: ContextSetup; @@ -57,18 +57,20 @@ export class HttpService implements CoreService<InternalHttpServiceSetup, HttpSe private readonly logger: LoggerFactory; private readonly log: Logger; + private readonly env: Env; private notReadyServer?: Server; private requestHandlerContext?: RequestHandlerContextContainer; constructor(private readonly coreContext: CoreContext) { - const { logger, configService } = coreContext; + const { logger, configService, env } = coreContext; this.logger = logger; + this.env = env; this.log = logger.get('http'); - this.config$ = combineLatest( + this.config$ = combineLatest([ configService.atPath<HttpConfigType>(httpConfig.path), - configService.atPath<CspConfigType>(cspConfig.path) - ).pipe(map(([http, csp]) => new HttpConfig(http, csp))); + configService.atPath<CspConfigType>(cspConfig.path), + ]).pipe(map(([http, csp]) => new HttpConfig(http, csp))); this.httpServer = new HttpServer(logger, 'Kibana'); this.httpsRedirectServer = new HttpsRedirectServer(logger.get('http', 'redirect', 'server')); } @@ -92,6 +94,9 @@ export class HttpService implements CoreService<InternalHttpServiceSetup, HttpSe } const { registerRouter, ...serverContract } = await this.httpServer.setup(config); + + registerCoreHandlers(serverContract, config, this.env); + const contract: InternalHttpServiceSetup = { ...serverContract, diff --git a/src/core/server/http/integration_tests/lifecycle_handlers.test.ts b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts new file mode 100644 index 0000000000000..f4c5f16870c7e --- /dev/null +++ b/src/core/server/http/integration_tests/lifecycle_handlers.test.ts @@ -0,0 +1,241 @@ +/* + * 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 { resolve } from 'path'; +import supertest from 'supertest'; +import { BehaviorSubject } from 'rxjs'; +import { ByteSizeValue } from '@kbn/config-schema'; + +import { createHttpServer } from '../test_utils'; +import { HttpService } from '../http_service'; +import { HttpServerSetup } from '../http_server'; +import { IRouter, RouteRegistrar } from '../router'; + +import { configServiceMock } from '../../config/config_service.mock'; +import { contextServiceMock } from '../../context/context_service.mock'; + +const pkgPath = resolve(__dirname, '../../../../../package.json'); +const actualVersion = require(pkgPath).version; +const versionHeader = 'kbn-version'; +const xsrfHeader = 'kbn-xsrf'; +const nameHeader = 'kbn-name'; +const whitelistedTestPath = '/xsrf/test/route/whitelisted'; +const kibanaName = 'my-kibana-name'; +const setupDeps = { + context: contextServiceMock.createSetupContract(), +}; + +describe('core lifecycle handlers', () => { + let server: HttpService; + let innerServer: HttpServerSetup['server']; + let router: IRouter; + + beforeEach(async () => { + const configService = configServiceMock.create(); + configService.atPath.mockReturnValue( + new BehaviorSubject({ + hosts: ['localhost'], + maxPayload: new ByteSizeValue(1024), + autoListen: true, + ssl: { + enabled: false, + }, + compression: { enabled: true }, + name: kibanaName, + customResponseHeaders: { + 'some-header': 'some-value', + }, + xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] }, + } as any) + ); + server = createHttpServer({ configService }); + + const serverSetup = await server.setup(setupDeps); + router = serverSetup.createRouter('/'); + innerServer = serverSetup.server; + }, 30000); + + afterEach(async () => { + await server.stop(); + }); + + describe('versionCheck post-auth handler', () => { + const testRoute = '/version_check/test/route'; + + beforeEach(async () => { + router.get({ path: testRoute, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + await server.start(); + }); + + it('accepts requests with the correct version passed in the version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); + }); + + it('accepts requests that do not include a version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + }); + + it('rejects requests with an incorrect version passed in the version header', async () => { + await supertest(innerServer.listener) + .get(testRoute) + .set(versionHeader, 'invalid-version') + .expect(400, /Browser client is out of date/); + }); + }); + + describe('customHeaders pre-response handler', () => { + const testRoute = '/custom_headers/test/route'; + const testErrorRoute = '/custom_headers/test/error_route'; + + beforeEach(async () => { + router.get({ path: testRoute, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + router.get({ path: testErrorRoute, validate: false }, (context, req, res) => { + return res.badRequest({ body: 'bad request' }); + }); + await server.start(); + }); + + it('adds the kbn-name header', async () => { + const result = await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + const headers = result.header as Record<string, string>; + expect(headers).toEqual( + expect.objectContaining({ + [nameHeader]: kibanaName, + }) + ); + }); + + it('adds the kbn-name header in case of error', async () => { + const result = await supertest(innerServer.listener) + .get(testErrorRoute) + .expect(400); + const headers = result.header as Record<string, string>; + expect(headers).toEqual( + expect.objectContaining({ + [nameHeader]: kibanaName, + }) + ); + }); + + it('adds the custom headers', async () => { + const result = await supertest(innerServer.listener) + .get(testRoute) + .expect(200, 'ok'); + const headers = result.header as Record<string, string>; + expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + }); + + it('adds the custom headers in case of error', async () => { + const result = await supertest(innerServer.listener) + .get(testErrorRoute) + .expect(400); + const headers = result.header as Record<string, string>; + expect(headers).toEqual(expect.objectContaining({ 'some-header': 'some-value' })); + }); + }); + + describe('xsrf post-auth handler', () => { + const testPath = '/xsrf/test/route'; + const destructiveMethods = ['POST', 'PUT', 'DELETE']; + const nonDestructiveMethods = ['GET', 'HEAD']; + + const getSupertest = (method: string, path: string): supertest.Test => { + return (supertest(innerServer.listener) as any)[method.toLowerCase()](path) as supertest.Test; + }; + + beforeEach(async () => { + router.get({ path: testPath, validate: false }, (context, req, res) => { + return res.ok({ body: 'ok' }); + }); + + destructiveMethods.forEach(method => { + ((router as any)[method.toLowerCase()] as RouteRegistrar<any>)<any, any, any>( + { path: testPath, validate: false }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); + ((router as any)[method.toLowerCase()] as RouteRegistrar<any>)<any, any, any>( + { path: whitelistedTestPath, validate: false }, + (context, req, res) => { + return res.ok({ body: 'ok' }); + } + ); + }); + + await server.start(); + }); + + nonDestructiveMethods.forEach(method => { + describe(`When using non-destructive ${method} method`, () => { + it('accepts requests without a token', async () => { + await getSupertest(method.toLowerCase(), testPath).expect( + 200, + method === 'HEAD' ? undefined : 'ok' + ); + }); + + it('accepts requests with the xsrf header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(xsrfHeader, 'anything') + .expect(200, method === 'HEAD' ? undefined : 'ok'); + }); + }); + }); + + destructiveMethods.forEach(method => { + describe(`When using destructive ${method} method`, () => { + it('accepts requests with the xsrf header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(xsrfHeader, 'anything') + .expect(200, 'ok'); + }); + + it('accepts requests with the version header', async () => { + await getSupertest(method.toLowerCase(), testPath) + .set(versionHeader, actualVersion) + .expect(200, 'ok'); + }); + + it('rejects requests without either an xsrf or version header', async () => { + await getSupertest(method.toLowerCase(), testPath).expect(400, { + statusCode: 400, + error: 'Bad Request', + message: 'Request must contain a kbn-xsrf header.', + }); + }); + + it('accepts whitelisted requests without either an xsrf or version header', async () => { + await getSupertest(method.toLowerCase(), whitelistedTestPath).expect(200, 'ok'); + }); + }); + }); + }); +}); diff --git a/src/core/server/http/lifecycle_handlers.test.ts b/src/core/server/http/lifecycle_handlers.test.ts new file mode 100644 index 0000000000000..48a6973b741ba --- /dev/null +++ b/src/core/server/http/lifecycle_handlers.test.ts @@ -0,0 +1,269 @@ +/* + * 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 { + createCustomHeadersPreResponseHandler, + createVersionCheckPostAuthHandler, + createXsrfPostAuthHandler, +} from './lifecycle_handlers'; +import { httpServerMock } from './http_server.mocks'; +import { HttpConfig } from './http_config'; +import { KibanaRequest, RouteMethod } from './router'; + +const createConfig = (partial: Partial<HttpConfig>): HttpConfig => partial as HttpConfig; + +const forgeRequest = ({ + headers = {}, + path = '/', + method = 'get', +}: Partial<{ + headers: Record<string, string>; + path: string; + method: RouteMethod; +}>): KibanaRequest => { + return httpServerMock.createKibanaRequest({ headers, path, method }); +}; + +describe('xsrf post-auth handler', () => { + let toolkit: ReturnType<typeof httpServerMock.createToolkit>; + let responseFactory: ReturnType<typeof httpServerMock.createLifecycleResponseFactory>; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + responseFactory = httpServerMock.createLifecycleResponseFactory(); + }); + + describe('non destructive methods', () => { + it('accepts requests without version or xsrf header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'get', headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + }); + + describe('destructive methods', () => { + it('accepts requests with xsrf header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: { 'kbn-xsrf': 'xsrf' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('accepts requests with version header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: { 'kbn-version': 'some-version' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('returns a bad request if called without xsrf or version header', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: false } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post' }); + + responseFactory.badRequest.mockReturnValue('badRequest' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).not.toHaveBeenCalled(); + expect(responseFactory.badRequest).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "body": "Request must contain a kbn-xsrf header.", + } + `); + expect(result).toEqual('badRequest'); + }); + + it('accepts requests if protection is disabled', () => { + const config = createConfig({ xsrf: { whitelist: [], disableProtection: true } }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + + it('accepts requests if path is whitelisted', () => { + const config = createConfig({ + xsrf: { whitelist: ['/some-path'], disableProtection: false }, + }); + const handler = createXsrfPostAuthHandler(config); + const request = forgeRequest({ method: 'post', headers: {}, path: '/some-path' }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(result).toEqual('next'); + }); + }); +}); + +describe('versionCheck post-auth handler', () => { + let toolkit: ReturnType<typeof httpServerMock.createToolkit>; + let responseFactory: ReturnType<typeof httpServerMock.createLifecycleResponseFactory>; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + responseFactory = httpServerMock.createLifecycleResponseFactory(); + }); + + it('forward the request to the next interceptor if header matches', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: { 'kbn-version': 'actual-version' } }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(result).toBe('next'); + }); + + it('returns a badRequest error if header does not match', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: { 'kbn-version': 'another-version' } }); + + responseFactory.badRequest.mockReturnValue('badRequest' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).not.toHaveBeenCalled(); + expect(responseFactory.badRequest).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "body": Object { + "attributes": Object { + "expected": "actual-version", + "got": "another-version", + }, + "message": "Browser client is out of date, please refresh the page (\\"kbn-version\\" header was \\"another-version\\" but should be \\"actual-version\\")", + }, + } + `); + expect(result).toBe('badRequest'); + }); + + it('forward the request to the next interceptor if header is not present', () => { + const handler = createVersionCheckPostAuthHandler('actual-version'); + const request = forgeRequest({ headers: {} }); + + toolkit.next.mockReturnValue('next' as any); + + const result = handler(request, responseFactory, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(responseFactory.badRequest).not.toHaveBeenCalled(); + expect(result).toBe('next'); + }); +}); + +describe('customHeaders pre-response handler', () => { + let toolkit: ReturnType<typeof httpServerMock.createToolkit>; + + beforeEach(() => { + toolkit = httpServerMock.createToolkit(); + }); + + it('adds the kbn-name header to the response', () => { + const config = createConfig({ name: 'my-server-name' }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ headers: { 'kbn-name': 'my-server-name' } }); + }); + + it('adds the custom headers defined in the configuration', () => { + const config = createConfig({ + name: 'my-server-name', + customResponseHeaders: { + headerA: 'value-A', + headerB: 'value-B', + }, + }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-name': 'my-server-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + }); + + it('preserve the kbn-name value from server.name if definied in custom headders ', () => { + const config = createConfig({ + name: 'my-server-name', + customResponseHeaders: { + 'kbn-name': 'custom-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + const handler = createCustomHeadersPreResponseHandler(config as HttpConfig); + + handler({} as any, {} as any, toolkit); + + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(toolkit.next).toHaveBeenCalledWith({ + headers: { + 'kbn-name': 'my-server-name', + headerA: 'value-A', + headerB: 'value-B', + }, + }); + }); +}); diff --git a/src/core/server/http/lifecycle_handlers.ts b/src/core/server/http/lifecycle_handlers.ts new file mode 100644 index 0000000000000..ee877ee031a2b --- /dev/null +++ b/src/core/server/http/lifecycle_handlers.ts @@ -0,0 +1,93 @@ +/* + * 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 { OnPostAuthHandler } from './lifecycle/on_post_auth'; +import { OnPreResponseHandler } from './lifecycle/on_pre_response'; +import { HttpConfig } from './http_config'; +import { Env } from '../config'; +import { LifecycleRegistrar } from './http_server'; + +const VERSION_HEADER = 'kbn-version'; +const XSRF_HEADER = 'kbn-xsrf'; +const KIBANA_NAME_HEADER = 'kbn-name'; + +export const createXsrfPostAuthHandler = (config: HttpConfig): OnPostAuthHandler => { + const { whitelist, disableProtection } = config.xsrf; + + return (request, response, toolkit) => { + if (disableProtection || whitelist.includes(request.route.path)) { + return toolkit.next(); + } + + const isSafeMethod = request.route.method === 'get' || request.route.method === 'head'; + const hasVersionHeader = VERSION_HEADER in request.headers; + const hasXsrfHeader = XSRF_HEADER in request.headers; + + if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { + return response.badRequest({ body: `Request must contain a ${XSRF_HEADER} header.` }); + } + + return toolkit.next(); + }; +}; + +export const createVersionCheckPostAuthHandler = (kibanaVersion: string): OnPostAuthHandler => { + return (request, response, toolkit) => { + const requestVersion = request.headers[VERSION_HEADER]; + if (requestVersion && requestVersion !== kibanaVersion) { + return response.badRequest({ + body: { + message: + `Browser client is out of date, please refresh the page ` + + `("${VERSION_HEADER}" header was "${requestVersion}" but should be "${kibanaVersion}")`, + attributes: { + expected: kibanaVersion, + got: requestVersion, + }, + }, + }); + } + + return toolkit.next(); + }; +}; + +export const createCustomHeadersPreResponseHandler = (config: HttpConfig): OnPreResponseHandler => { + const serverName = config.name; + const customHeaders = config.customResponseHeaders; + + return (request, response, toolkit) => { + const additionalHeaders = { + ...customHeaders, + [KIBANA_NAME_HEADER]: serverName, + }; + + return toolkit.next({ headers: additionalHeaders }); + }; +}; + +export const registerCoreHandlers = ( + registrar: LifecycleRegistrar, + config: HttpConfig, + env: Env +) => { + registrar.registerOnPreResponse(createCustomHeadersPreResponseHandler(config)); + registrar.registerOnPostAuth(createXsrfPostAuthHandler(config)); + registrar.registerOnPostAuth(createVersionCheckPostAuthHandler(env.packageInfo.version)); +}; diff --git a/src/core/server/http/test_utils.ts b/src/core/server/http/test_utils.ts index e0a15cdc6e839..ffdc04d156ca0 100644 --- a/src/core/server/http/test_utils.ts +++ b/src/core/server/http/test_utils.ts @@ -41,6 +41,10 @@ configService.atPath.mockReturnValue( enabled: false, }, compression: { enabled: true }, + xsrf: { + disableProtection: true, + whitelist: [], + }, } as any) ); diff --git a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap index 0ebd8b8371628..ac63f424eabaf 100644 --- a/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap +++ b/src/core/server/legacy/config/__snapshots__/legacy_object_to_config_adapter.test.ts.snap @@ -8,10 +8,14 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object { + "custom-header": "custom-value", + }, "defaultRoute": undefined, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, + "name": "kibana-hostname", "port": 1234, "rewriteBasePath": false, "socketTimeout": 2000, @@ -21,6 +25,10 @@ Object { "someNewValue": "new", }, "uuid": undefined, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; @@ -32,10 +40,14 @@ Object { "enabled": true, }, "cors": false, + "customResponseHeaders": Object { + "custom-header": "custom-value", + }, "defaultRoute": undefined, "host": "host", "keepaliveTimeout": 5000, "maxPayload": 1000, + "name": "kibana-hostname", "port": 1234, "rewriteBasePath": false, "socketTimeout": 2000, @@ -45,6 +57,10 @@ Object { "key": "key", }, "uuid": undefined, + "xsrf": Object { + "disableProtection": false, + "whitelist": Array [], + }, } `; diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts index db2bc117280ca..1c51564187442 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.test.ts @@ -80,9 +80,11 @@ describe('#get', () => { test('correctly handles server config.', () => { const configAdapter = new LegacyObjectToConfigAdapter({ server: { + name: 'kibana-hostname', autoListen: true, basePath: '/abc', cors: false, + customResponseHeaders: { 'custom-header': 'custom-value' }, host: 'host', maxPayloadBytes: 1000, keepaliveTimeout: 5000, @@ -92,14 +94,20 @@ describe('#get', () => { ssl: { enabled: true, keyPassphrase: 'some-phrase', someNewValue: 'new' }, compression: { enabled: true }, someNotSupportedValue: 'val', + xsrf: { + disableProtection: false, + whitelist: [], + }, }, }); const configAdapterWithDisabledSSL = new LegacyObjectToConfigAdapter({ server: { + name: 'kibana-hostname', autoListen: true, basePath: '/abc', cors: false, + customResponseHeaders: { 'custom-header': 'custom-value' }, host: 'host', maxPayloadBytes: 1000, keepaliveTimeout: 5000, @@ -109,6 +117,10 @@ describe('#get', () => { ssl: { enabled: false, certificate: 'cert', key: 'key' }, compression: { enabled: true }, someNotSupportedValue: 'val', + xsrf: { + disableProtection: false, + whitelist: [], + }, }, }); diff --git a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts index bdcde8262ef98..397e7a46def58 100644 --- a/src/core/server/legacy/config/legacy_object_to_config_adapter.ts +++ b/src/core/server/legacy/config/legacy_object_to_config_adapter.ts @@ -60,14 +60,16 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { private static transformServer(configValue: any = {}) { // TODO: New platform uses just a subset of `server` config from the legacy platform, - // new values will be exposed once we need them (eg. customResponseHeaders or xsrf). + // new values will be exposed once we need them return { autoListen: configValue.autoListen, basePath: configValue.basePath, defaultRoute: configValue.defaultRoute, cors: configValue.cors, + customResponseHeaders: configValue.customResponseHeaders, host: configValue.host, maxPayload: configValue.maxPayloadBytes, + name: configValue.name, port: configValue.port, rewriteBasePath: configValue.rewriteBasePath, ssl: configValue.ssl, @@ -75,6 +77,7 @@ export class LegacyObjectToConfigAdapter extends ObjectToConfigAdapter { socketTimeout: configValue.socketTimeout, compression: configValue.compression, uuid: configValue.uuid, + xsrf: configValue.xsrf, }; } diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index 193011c0b8be9..64e778642aad9 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -71,19 +71,6 @@ export default () => server: Joi.object({ name: Joi.string().default(os.hostname()), defaultRoute: Joi.string().regex(/^\//, `start with a slash`), - customResponseHeaders: Joi.object() - .unknown(true) - .default({}), - xsrf: Joi.object({ - disableProtection: Joi.boolean().default(false), - whitelist: Joi.array() - .items(Joi.string().regex(/^\//, 'start with a slash')) - .default([]), - token: Joi.string() - .optional() - .notes('Deprecated'), - }).default(), - // keep them for BWC, remove when not used in Legacy. // validation should be in sync with one in New platform. // https://github.com/elastic/kibana/blob/master/src/core/server/http/http_config.ts @@ -103,12 +90,14 @@ export default () => autoListen: HANDLED_IN_NEW_PLATFORM, cors: HANDLED_IN_NEW_PLATFORM, + customResponseHeaders: HANDLED_IN_NEW_PLATFORM, keepaliveTimeout: HANDLED_IN_NEW_PLATFORM, maxPayloadBytes: HANDLED_IN_NEW_PLATFORM, socketTimeout: HANDLED_IN_NEW_PLATFORM, ssl: HANDLED_IN_NEW_PLATFORM, compression: HANDLED_IN_NEW_PLATFORM, uuid: HANDLED_IN_NEW_PLATFORM, + xsrf: HANDLED_IN_NEW_PLATFORM, }).default(), uiSettings: HANDLED_IN_NEW_PLATFORM, diff --git a/src/legacy/server/config/schema.test.js b/src/legacy/server/config/schema.test.js index 1207a05a47497..03d2fe53c2ce7 100644 --- a/src/legacy/server/config/schema.test.js +++ b/src/legacy/server/config/schema.test.js @@ -19,7 +19,6 @@ import schemaProvider from './schema'; import Joi from 'joi'; -import { set } from 'lodash'; describe('Config schema', function() { let schema; @@ -100,60 +99,5 @@ describe('Config schema', function() { expect(error.details[0]).toHaveProperty('path', ['server', 'rewriteBasePath']); }); }); - - describe('xsrf', () => { - it('disableProtection is `false` by default.', () => { - const { - error, - value: { - server: { - xsrf: { disableProtection }, - }, - }, - } = validate({}); - expect(error).toBe(null); - expect(disableProtection).toBe(false); - }); - - it('whitelist is empty by default.', () => { - const { - value: { - server: { - xsrf: { whitelist }, - }, - }, - } = validate({}); - expect(whitelist).toBeInstanceOf(Array); - expect(whitelist).toHaveLength(0); - }); - - it('whitelist rejects paths that do not start with a slash.', () => { - const config = {}; - set(config, 'server.xsrf.whitelist', ['path/to']); - - const { error } = validate(config); - expect(error).toBeInstanceOf(Object); - expect(error).toHaveProperty('details'); - expect(error.details[0]).toHaveProperty('path', ['server', 'xsrf', 'whitelist', 0]); - }); - - it('whitelist accepts paths that start with a slash.', () => { - const config = {}; - set(config, 'server.xsrf.whitelist', ['/path/to']); - - const { - error, - value: { - server: { - xsrf: { whitelist }, - }, - }, - } = validate(config); - expect(error).toBe(null); - expect(whitelist).toBeInstanceOf(Array); - expect(whitelist).toHaveLength(1); - expect(whitelist).toContain('/path/to'); - }); - }); }); }); diff --git a/src/legacy/server/http/index.js b/src/legacy/server/http/index.js index 9b5ce2046c5d3..265d71e95b301 100644 --- a/src/legacy/server/http/index.js +++ b/src/legacy/server/http/index.js @@ -22,11 +22,9 @@ import { resolve } from 'path'; import _ from 'lodash'; import Boom from 'boom'; -import { setupVersionCheck } from './version_check'; import { registerHapiPlugins } from './register_hapi_plugins'; import { setupBasePathProvider } from './setup_base_path_provider'; import { setupDefaultRouteProvider } from './setup_default_route_provider'; -import { setupXsrf } from './xsrf'; export default async function(kbnServer, server, config) { server = kbnServer.server; @@ -62,29 +60,6 @@ export default async function(kbnServer, server, config) { }); }); - // attach the app name to the server, so we can be sure we are actually talking to kibana - server.ext('onPreResponse', function onPreResponse(req, h) { - const response = req.response; - - const customHeaders = { - ...config.get('server.customResponseHeaders'), - 'kbn-name': kbnServer.name, - }; - - if (response.isBoom) { - response.output.headers = { - ...response.output.headers, - ...customHeaders, - }; - } else { - Object.keys(customHeaders).forEach(name => { - response.header(name, customHeaders[name]); - }); - } - - return h.continue; - }); - server.route({ path: '/', method: 'GET', @@ -116,7 +91,4 @@ export default async function(kbnServer, server, config) { // Expose static assets server.exposeStaticDir('/ui/{path*}', resolve(__dirname, '../../ui/public/assets')); - - setupVersionCheck(server, config); - setupXsrf(server, config); } diff --git a/src/legacy/server/http/integration_tests/version_check.test.js b/src/legacy/server/http/integration_tests/version_check.test.js deleted file mode 100644 index 8d71c98d64969..0000000000000 --- a/src/legacy/server/http/integration_tests/version_check.test.js +++ /dev/null @@ -1,64 +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 { resolve } from 'path'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; - -const src = resolve.bind(null, __dirname, '../../../../../src'); - -const versionHeader = 'kbn-version'; -const version = require(src('../package.json')).version; // eslint-disable-line import/no-dynamic-require - -describe('version_check request filter', function() { - let root; - beforeAll(async () => { - root = kbnTestServer.createRoot(); - - await root.setup(); - await root.start(); - - kbnTestServer.getKbnServer(root).server.route({ - path: '/version_check/test/route', - method: 'GET', - handler: function() { - return 'ok'; - }, - }); - }, 30000); - - afterAll(async () => await root.shutdown()); - - it('accepts requests with the correct version passed in the version header', async function() { - await kbnTestServer.request - .get(root, '/version_check/test/route') - .set(versionHeader, version) - .expect(200, 'ok'); - }); - - it('rejects requests with an incorrect version passed in the version header', async function() { - await kbnTestServer.request - .get(root, '/version_check/test/route') - .set(versionHeader, `invalid:${version}`) - .expect(400, /"Browser client is out of date/); - }); - - it('accepts requests that do not include a version header', async function() { - await kbnTestServer.request.get(root, '/version_check/test/route').expect(200, 'ok'); - }); -}); diff --git a/src/legacy/server/http/integration_tests/xsrf.test.js b/src/legacy/server/http/integration_tests/xsrf.test.js deleted file mode 100644 index a06f4eec4fd5c..0000000000000 --- a/src/legacy/server/http/integration_tests/xsrf.test.js +++ /dev/null @@ -1,145 +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 { resolve } from 'path'; -import * as kbnTestServer from '../../../../test_utils/kbn_server'; - -const destructiveMethods = ['POST', 'PUT', 'DELETE']; -const src = resolve.bind(null, __dirname, '../../../../../src'); - -const xsrfHeader = 'kbn-xsrf'; -const versionHeader = 'kbn-version'; -const testPath = '/xsrf/test/route'; -const whitelistedTestPath = '/xsrf/test/route/whitelisted'; -const actualVersion = require(src('../package.json')).version; // eslint-disable-line import/no-dynamic-require - -describe('xsrf request filter', () => { - let root; - beforeAll(async () => { - root = kbnTestServer.createRoot({ - server: { - xsrf: { disableProtection: false, whitelist: [whitelistedTestPath] }, - }, - }); - - await root.setup(); - await root.start(); - - const kbnServer = kbnTestServer.getKbnServer(root); - kbnServer.server.route({ - path: testPath, - method: 'GET', - handler: async function() { - return 'ok'; - }, - }); - - kbnServer.server.route({ - path: testPath, - method: destructiveMethods, - config: { - // Disable payload parsing to make HapiJS server accept any content-type header. - payload: { - parse: false, - }, - validate: { payload: null }, - }, - handler: async function() { - return 'ok'; - }, - }); - - kbnServer.server.route({ - path: whitelistedTestPath, - method: destructiveMethods, - config: { - // Disable payload parsing to make HapiJS server accept any content-type header. - payload: { - parse: false, - }, - validate: { payload: null }, - }, - handler: async function() { - return 'ok'; - }, - }); - }, 30000); - - afterAll(async () => await root.shutdown()); - - describe(`nonDestructiveMethod: GET`, function() { - it('accepts requests without a token', async function() { - await kbnTestServer.request.get(root, testPath).expect(200, 'ok'); - }); - - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request - .get(root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, 'ok'); - }); - }); - - describe(`nonDestructiveMethod: HEAD`, function() { - it('accepts requests without a token', async function() { - await kbnTestServer.request.head(root, testPath).expect(200, undefined); - }); - - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request - .head(root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, undefined); - }); - }); - - for (const method of destructiveMethods) { - // eslint-disable-next-line no-loop-func - describe(`destructiveMethod: ${method}`, function() { - it('accepts requests with the xsrf header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath) - .set(xsrfHeader, 'anything') - .expect(200, 'ok'); - }); - - // this is still valid for existing csrf protection support - // it does not actually do any validation on the version value itself - it('accepts requests with the version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath) - .set(versionHeader, actualVersion) - .expect(200, 'ok'); - }); - - it('rejects requests without either an xsrf or version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, testPath).expect(400, { - statusCode: 400, - error: 'Bad Request', - message: 'Request must contain a kbn-xsrf header.', - }); - }); - - it('accepts whitelisted requests without either an xsrf or version header', async function() { - await kbnTestServer.request[method.toLowerCase()](root, whitelistedTestPath).expect( - 200, - 'ok' - ); - }); - }); - } -}); diff --git a/src/legacy/server/http/version_check.js b/src/legacy/server/http/version_check.js deleted file mode 100644 index 12666c9a0f3f6..0000000000000 --- a/src/legacy/server/http/version_check.js +++ /dev/null @@ -1,39 +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 { badRequest } from 'boom'; - -export function setupVersionCheck(server, config) { - const versionHeader = 'kbn-version'; - const actualVersion = config.get('pkg.version'); - - server.ext('onPostAuth', function onPostAuthVersionCheck(req, h) { - const versionRequested = req.headers[versionHeader]; - - if (versionRequested && versionRequested !== actualVersion) { - throw badRequest( - `Browser client is out of date, \ - please refresh the page ("${versionHeader}" header was "${versionRequested}" but should be "${actualVersion}")`, - { expected: actualVersion, got: versionRequested } - ); - } - - return h.continue; - }); -} diff --git a/src/legacy/server/http/xsrf.js b/src/legacy/server/http/xsrf.js deleted file mode 100644 index 79ac3af6d9f90..0000000000000 --- a/src/legacy/server/http/xsrf.js +++ /dev/null @@ -1,47 +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 { badRequest } from 'boom'; - -export function setupXsrf(server, config) { - const disabled = config.get('server.xsrf.disableProtection'); - const whitelist = config.get('server.xsrf.whitelist'); - const versionHeader = 'kbn-version'; - const xsrfHeader = 'kbn-xsrf'; - - server.ext('onPostAuth', function onPostAuthXsrf(req, h) { - if (disabled) { - return h.continue; - } - - if (whitelist.includes(req.path)) { - return h.continue; - } - - const isSafeMethod = req.method === 'get' || req.method === 'head'; - const hasVersionHeader = versionHeader in req.headers; - const hasXsrfHeader = xsrfHeader in req.headers; - - if (!isSafeMethod && !hasVersionHeader && !hasXsrfHeader) { - throw badRequest(`Request must contain a ${xsrfHeader} header.`); - } - - return h.continue; - }); -} diff --git a/src/test_utils/kbn_server.ts b/src/test_utils/kbn_server.ts index 370c7554b2cc2..0ed33928ff63c 100644 --- a/src/test_utils/kbn_server.ts +++ b/src/test_utils/kbn_server.ts @@ -37,7 +37,7 @@ import { Root } from '../core/server/root'; import KbnServer from '../legacy/server/kbn_server'; import { CallCluster } from '../legacy/core_plugins/elasticsearch'; -type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; +export type HttpMethod = 'delete' | 'get' | 'head' | 'post' | 'put'; const DEFAULTS_SETTINGS = { server: { @@ -97,7 +97,7 @@ export function createRootWithSettings( * @param method * @param path */ -function getSupertest(root: Root, method: HttpMethod, path: string) { +export function getSupertest(root: Root, method: HttpMethod, path: string) { const testUserCredentials = Buffer.from(`${kibanaTestUser.username}:${kibanaTestUser.password}`); return supertest((root as any).server.http.httpServer.server.listener) [method](path) From 447c15d0d2be5342c65d9afc7204db77903582fb Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris <github@gidi.io> Date: Mon, 6 Jan 2020 17:17:38 +0000 Subject: [PATCH 2/3] allows Alerts to recover gracefully from Executor errors (#53688) (#54018) Prevents errors in Alert Executors from forcing their underlying tasks into a zombie state. --- .../alert_instance.test.ts | 0 .../{lib => alert_instance}/alert_instance.ts | 2 +- .../create_alert_instance_factory.test.ts | 0 .../create_alert_instance_factory.ts | 0 .../alerting/server/alert_instance/index.ts | 8 + .../server/alert_type_registry.test.ts | 2 +- .../alerting/server/alert_type_registry.ts | 4 +- .../{lib => }/alerts_client_factory.test.ts | 26 +- .../server/{lib => }/alerts_client_factory.ts | 12 +- .../plugins/alerting/server/lib/index.ts | 6 +- .../alerting/server/lib/result_type.ts | 54 +++ .../server/lib/task_runner_factory.test.ts | 345 --------------- .../server/lib/task_runner_factory.ts | 190 --------- .../legacy/plugins/alerting/server/plugin.ts | 3 +- .../create_execution_handler.test.ts | 0 .../create_execution_handler.ts | 0 .../get_next_run_at.test.ts | 0 .../{lib => task_runner}/get_next_run_at.ts | 2 +- .../alerting/server/task_runner/index.ts | 7 + .../server/task_runner/task_runner.test.ts | 400 ++++++++++++++++++ .../server/task_runner/task_runner.ts | 241 +++++++++++ .../task_runner/task_runner_factory.test.ts | 87 ++++ .../server/task_runner/task_runner_factory.ts | 46 ++ .../transform_action_params.test.ts | 0 .../transform_action_params.ts | 0 .../legacy/plugins/alerting/server/types.ts | 2 +- .../common/lib/alert_utils.ts | 47 +- .../common/lib/index.ts | 1 + .../common/lib/test_assertions.ts | 17 + .../tests/alerting/update.ts | 15 +- .../spaces_only/tests/alerting/alerts.ts | 40 ++ 31 files changed, 982 insertions(+), 575 deletions(-) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/alert_instance.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/alert_instance.ts (97%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/create_alert_instance_factory.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => alert_instance}/create_alert_instance_factory.ts (100%) create mode 100644 x-pack/legacy/plugins/alerting/server/alert_instance/index.ts rename x-pack/legacy/plugins/alerting/server/{lib => }/alerts_client_factory.test.ts (81%) rename x-pack/legacy/plugins/alerting/server/{lib => }/alerts_client_factory.ts (92%) create mode 100644 x-pack/legacy/plugins/alerting/server/lib/result_type.ts delete mode 100644 x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts delete mode 100644 x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/create_execution_handler.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/create_execution_handler.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/get_next_run_at.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/get_next_run_at.ts (92%) create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/index.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts create mode 100644 x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/transform_action_params.test.ts (100%) rename x-pack/legacy/plugins/alerting/server/{lib => task_runner}/transform_action_params.ts (100%) create mode 100644 x-pack/test/alerting_api_integration/common/lib/test_assertions.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/alert_instance.test.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts similarity index 97% rename from x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts index 1e2cc26f364ad..a56e2077cdfd8 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alert_instance.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/alert_instance.ts @@ -5,7 +5,7 @@ */ import { State, Context } from '../types'; -import { parseDuration } from './parse_duration'; +import { parseDuration } from '../lib'; interface Meta { lastScheduledActions?: { diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.test.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_alert_instance_factory.ts rename to x-pack/legacy/plugins/alerting/server/alert_instance/create_alert_instance_factory.ts diff --git a/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts new file mode 100644 index 0000000000000..40ee0874e805c --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/alert_instance/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertInstance } from './alert_instance'; +export { createAlertInstanceFactory } from './create_alert_instance_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 57e1b965960e8..8e96ad8dae31c 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TaskRunnerFactory } from './lib'; +import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index b7512864c2a98..2003e810a05b5 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,8 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { TaskRunnerFactory } from './lib'; -import { RunContext } from '../../task_manager/server'; +import { TaskRunnerFactory } from './task_runner'; +import { RunContext } from '../../task_manager'; import { TaskManagerSetupContract } from './shim'; import { AlertType } from './types'; diff --git a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts similarity index 81% rename from x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts rename to x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts index 838c567fb2878..519001d07e089 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts @@ -6,13 +6,13 @@ import { Request } from 'hapi'; import { AlertsClientFactory, ConstructorOpts } from './alerts_client_factory'; -import { alertTypeRegistryMock } from '../alert_type_registry.mock'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; -import { KibanaRequest } from '../../../../../../src/core/server'; -import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; -import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { alertTypeRegistryMock } from './alert_type_registry.mock'; +import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -jest.mock('../alerts_client'); +jest.mock('./alerts_client'); const savedObjectsClient = jest.fn(); const securityPluginSetup = { @@ -55,7 +55,7 @@ test('creates an alerts client with proper constructor arguments', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - expect(jest.requireMock('../alerts_client').AlertsClient).toHaveBeenCalledWith({ + expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({ savedObjectsClient, logger: alertsClientFactoryParams.logger, taskManager: alertsClientFactoryParams.taskManager, @@ -72,7 +72,7 @@ test('creates an alerts client with proper constructor arguments', async () => { test('getUserName() returns null when security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const userNameResult = await constructorCall.getUserName(); expect(userNameResult).toEqual(null); @@ -84,7 +84,7 @@ test('getUserName() returns a name when security is enabled', async () => { securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.getCurrentUser.mockResolvedValueOnce({ username: 'bob' }); const userNameResult = await constructorCall.getUserName(); @@ -94,7 +94,7 @@ test('getUserName() returns a name when security is enabled', async () => { test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const createAPIKeyResult = await constructorCall.createAPIKey(); expect(createAPIKeyResult).toEqual({ apiKeysEnabled: false }); @@ -103,7 +103,7 @@ test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled but ES security is disabled', async () => { const factory = new AlertsClientFactory(alertsClientFactoryParams); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockResolvedValueOnce(null); const createAPIKeyResult = await constructorCall.createAPIKey(); @@ -116,7 +116,7 @@ test('createAPIKey() returns an API key when security is enabled', async () => { securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockResolvedValueOnce({ api_key: '123', id: 'abc' }); const createAPIKeyResult = await constructorCall.createAPIKey(); @@ -132,7 +132,7 @@ test('createAPIKey() throws when security plugin createAPIKey throws an error', securityPluginSetup: securityPluginSetup as any, }); factory.create(KibanaRequest.from(fakeRequest), fakeRequest); - const constructorCall = jest.requireMock('../alerts_client').AlertsClient.mock.calls[0][0]; + const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.createAPIKey.mockRejectedValueOnce(new Error('TLS disabled')); await expect(constructorCall.createAPIKey()).rejects.toThrowErrorMatchingInlineSnapshot( diff --git a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts similarity index 92% rename from x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts rename to x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts index 026d6c92b0d75..94a396fbaa806 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/alerts_client_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts @@ -6,12 +6,12 @@ import Hapi from 'hapi'; import uuid from 'uuid'; -import { AlertsClient } from '../alerts_client'; -import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from '../types'; -import { SecurityPluginStartContract, TaskManagerStartContract } from '../shim'; -import { KibanaRequest, Logger } from '../../../../../../src/core/server'; -import { InvalidateAPIKeyParams } from '../../../../../plugins/security/server'; -import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; +import { AlertsClient } from './alerts_client'; +import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; +import { SecurityPluginStartContract, TaskManagerStartContract } from './shim'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { InvalidateAPIKeyParams } from '../../../../plugins/security/server'; +import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; export interface ConstructorOpts { logger: Logger; diff --git a/x-pack/legacy/plugins/alerting/server/lib/index.ts b/x-pack/legacy/plugins/alerting/server/lib/index.ts index ca4ddf9e11ad2..c41ea4a5998ff 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/index.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/index.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { AlertInstance } from './alert_instance'; -export { validateAlertTypeParams } from './validate_alert_type_params'; export { parseDuration, getDurationSchema } from './parse_duration'; -export { AlertsClientFactory } from './alerts_client_factory'; -export { TaskRunnerFactory } from './task_runner_factory'; +export { LicenseState } from './license_state'; +export { validateAlertTypeParams } from './validate_alert_type_params'; diff --git a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts new file mode 100644 index 0000000000000..644ae51292249 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Ok<T> { + tag: 'ok'; + value: T; +} + +export interface Err<E> { + tag: 'err'; + error: E; +} +export type Result<T, E> = Ok<T> | Err<E>; + +export function asOk<T>(value: T): Ok<T> { + return { + tag: 'ok', + value, + }; +} + +export function asErr<T>(error: T): Err<T> { + return { + tag: 'err', + error, + }; +} + +export function isOk<T, E>(result: Result<T, E>): result is Ok<T> { + return result.tag === 'ok'; +} + +export function isErr<T, E>(result: Result<T, E>): result is Err<E> { + return !isOk(result); +} + +export async function promiseResult<T, E>(future: Promise<T>): Promise<Result<T, E>> { + try { + return asOk(await future); + } catch (e) { + return asErr(e); + } +} + +export function map<T, E, Resolution>( + result: Result<T, E>, + onOk: (value: T) => Resolution, + onErr: (error: E) => Resolution +): Resolution { + return isOk(result) ? onOk(result.value) : onErr(result.error); +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts deleted file mode 100644 index fd13452e04535..0000000000000 --- a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import sinon from 'sinon'; -import { schema } from '@kbn/config-schema'; -import { AlertExecutorOptions } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; -import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; -import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; -import { - savedObjectsClientMock, - loggingServiceMock, -} from '../../../../../../src/core/server/mocks'; - -const alertType = { - id: 'test', - name: 'My test alert', - actionGroups: ['default'], - executor: jest.fn(), -}; -let fakeTimer: sinon.SinonFakeTimers; -let taskRunnerFactory: TaskRunnerFactory; -let mockedTaskInstance: ConcreteTaskInstance; - -beforeAll(() => { - fakeTimer = sinon.useFakeTimers(); - mockedTaskInstance = { - id: '', - attempts: 0, - status: TaskStatus.Running, - version: '123', - runAt: new Date(), - scheduledAt: new Date(), - startedAt: new Date(), - retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: { - startedAt: new Date(Date.now() - 5 * 60 * 1000), - }, - taskType: 'alerting:test', - params: { - alertId: '1', - }, - ownerId: null, - }; - taskRunnerFactory = new TaskRunnerFactory(); - taskRunnerFactory.initialize(taskRunnerFactoryInitializerParams); -}); - -afterAll(() => fakeTimer.restore()); - -const savedObjectsClient = savedObjectsClientMock.create(); -const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); -const services = { - log: jest.fn(), - callCluster: jest.fn(), - savedObjectsClient, -}; - -const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = { - getServices: jest.fn().mockReturnValue(services), - executeAction: jest.fn(), - encryptedSavedObjectsPlugin, - logger: loggingServiceMock.create().get(), - spaceIdToNamespace: jest.fn().mockReturnValue(undefined), - getBasePath: jest.fn().mockReturnValue(undefined), -}; - -const mockedAlertTypeSavedObject = { - id: '1', - type: 'alert', - attributes: { - enabled: true, - alertTypeId: '123', - schedule: { interval: '10s' }, - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - actionRef: 'action_0', - params: { - foo: true, - }, - }, - ], - }, - references: [ - { - name: 'action_0', - type: 'action', - id: '1', - }, - ], -}; - -beforeEach(() => { - jest.resetAllMocks(); - taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); -}); - -test(`throws an error if factory isn't initialized`, () => { - const factory = new TaskRunnerFactory(); - expect(() => - factory.create(alertType, { taskInstance: mockedTaskInstance }) - ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); -}); - -test(`throws an error if factory is already initialized`, () => { - const factory = new TaskRunnerFactory(); - factory.initialize(taskRunnerFactoryInitializerParams); - expect(() => - factory.initialize(taskRunnerFactoryInitializerParams) - ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); -}); - -test('successfully executes the task', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult).toMatchInlineSnapshot(` - Object { - "runAt": 1970-01-01T00:00:10.000Z, - "state": Object { - "alertInstances": Object {}, - "alertTypeState": undefined, - "previousStartedAt": 1970-01-01T00:00:00.000Z, - }, - } - `); - expect(alertType.executor).toHaveBeenCalledTimes(1); - const call = alertType.executor.mock.calls[0][0]; - expect(call.params).toMatchInlineSnapshot(` - Object { - "bar": true, - } - `); - expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); - expect(call.state).toMatchInlineSnapshot(`Object {}`); - expect(call.services.alertInstanceFactory).toBeTruthy(); - expect(call.services.callCluster).toBeTruthy(); - expect(call.services).toBeTruthy(); -}); - -test('executeAction is called per alert instance that is scheduled', async () => { - alertType.executor.mockImplementation(({ services: executorServices }: AlertExecutorOptions) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - }); - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1); - expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "apiKey": "MTIzOmFiYw==", - "id": "1", - "params": Object { - "foo": true, - }, - "spaceId": undefined, - }, - ] - `); -}); - -test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { - alertType.executor.mockImplementation(({ services: executorServices }: AlertExecutorOptions) => { - executorServices.alertInstanceFactory('1').scheduleActions('default'); - }); - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: { - ...mockedTaskInstance, - state: { - ...mockedTaskInstance.state, - alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, - }, - }, - }, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - const runnerResult = await taskRunner.run(); - expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` - Object { - "1": Object { - "meta": Object { - "lastScheduledActions": Object { - "date": 1970-01-01T00:00:00.000Z, - "group": "default", - }, - }, - "state": Object { - "bar": false, - }, - }, - } - `); -}); - -test('validates params before executing the alert type', async () => { - const taskRunner = taskRunnerFactory.create( - { - ...alertType, - validate: { - params: schema.object({ - param1: schema.string(), - }), - }, - }, - { taskInstance: mockedTaskInstance } - ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( - `"params invalid: [param1]: expected value of type [string] but got [undefined]"` - ); -}); - -test('throws error if reference not found', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce({ - ...mockedAlertTypeSavedObject, - references: [], - }); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( - `"Action reference \\"action_0\\" not found in alert id: 1"` - ); -}); - -test('uses API key when provided', async () => { - const taskRunner = taskRunnerFactory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - - await taskRunner.run(); - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: { - // base64 encoded "123:abc" - authorization: 'ApiKey MTIzOmFiYw==', - }, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); -}); - -test(`doesn't use API key when not provided`, async () => { - const factory = new TaskRunnerFactory(); - factory.initialize(taskRunnerFactoryInitializerParams); - const taskRunner = factory.create(alertType, { - taskInstance: mockedTaskInstance, - }); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); - encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: {}, - references: [], - }); - - await taskRunner.run(); - - expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ - getBasePath: expect.anything(), - headers: {}, - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }); -}); diff --git a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts deleted file mode 100644 index 5614188795ded..0000000000000 --- a/x-pack/legacy/plugins/alerting/server/lib/task_runner_factory.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Logger } from '../../../../../../src/core/server'; -import { RunContext } from '../../../task_manager/server'; -import { createExecutionHandler } from './create_execution_handler'; -import { createAlertInstanceFactory } from './create_alert_instance_factory'; -import { AlertInstance } from './alert_instance'; -import { getNextRunAt } from './get_next_run_at'; -import { validateAlertTypeParams } from './validate_alert_type_params'; -import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; -import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; -import { - AlertType, - AlertServices, - GetBasePathFunction, - GetServicesFunction, - RawAlert, - SpaceIdToNamespaceFunction, - IntervalSchedule, -} from '../types'; - -export interface TaskRunnerContext { - logger: Logger; - getServices: GetServicesFunction; - executeAction: ActionsPluginStartContract['execute']; - encryptedSavedObjectsPlugin: EncryptedSavedObjectsStartContract; - spaceIdToNamespace: SpaceIdToNamespaceFunction; - getBasePath: GetBasePathFunction; -} - -export class TaskRunnerFactory { - private isInitialized = false; - private taskRunnerContext?: TaskRunnerContext; - - public initialize(taskRunnerContext: TaskRunnerContext) { - if (this.isInitialized) { - throw new Error('TaskRunnerFactory already initialized'); - } - this.isInitialized = true; - this.taskRunnerContext = taskRunnerContext; - } - - public create(alertType: AlertType, { taskInstance }: RunContext) { - if (!this.isInitialized) { - throw new Error('TaskRunnerFactory not initialized'); - } - - const { - logger, - getServices, - executeAction, - encryptedSavedObjectsPlugin, - spaceIdToNamespace, - getBasePath, - } = this.taskRunnerContext!; - - return { - async run() { - const { alertId, spaceId } = taskInstance.params; - const requestHeaders: Record<string, string> = {}; - const namespace = spaceIdToNamespace(spaceId); - // Only fetch encrypted attributes here, we'll create a saved objects client - // scoped with the API key to fetch the remaining data. - const { - attributes: { apiKey }, - } = await encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAlert>( - 'alert', - alertId, - { namespace } - ); - - if (apiKey) { - requestHeaders.authorization = `ApiKey ${apiKey}`; - } - - const fakeRequest = { - headers: requestHeaders, - getBasePath: () => getBasePath(spaceId), - path: '/', - route: { settings: {} }, - url: { - href: '/', - }, - raw: { - req: { - url: '/', - }, - }, - }; - - const services = getServices(fakeRequest); - // Ensure API key is still valid and user has access - const { - attributes: { params, actions, schedule, throttle, muteAll, mutedInstanceIds }, - references, - } = await services.savedObjectsClient.get<RawAlert>('alert', alertId); - - // Validate - const validatedAlertTypeParams = validateAlertTypeParams(alertType, params); - - // Inject ids into actions - const actionsWithIds = actions.map(action => { - const actionReference = references.find(obj => obj.name === action.actionRef); - if (!actionReference) { - throw new Error( - `Action reference "${action.actionRef}" not found in alert id: ${alertId}` - ); - } - return { - ...action, - id: actionReference.id, - }; - }); - - const executionHandler = createExecutionHandler({ - alertId, - logger, - executeAction, - apiKey, - actions: actionsWithIds, - spaceId, - alertType, - }); - const alertInstances: Record<string, AlertInstance> = {}; - const alertInstancesData = taskInstance.state.alertInstances || {}; - for (const id of Object.keys(alertInstancesData)) { - alertInstances[id] = new AlertInstance(alertInstancesData[id]); - } - const alertInstanceFactory = createAlertInstanceFactory(alertInstances); - - const alertTypeServices: AlertServices = { - ...services, - alertInstanceFactory, - }; - - const alertTypeState = await alertType.executor({ - alertId, - services: alertTypeServices, - params: validatedAlertTypeParams, - state: taskInstance.state.alertTypeState || {}, - startedAt: taskInstance.startedAt!, - previousStartedAt: taskInstance.state.previousStartedAt, - }); - - await Promise.all( - Object.keys(alertInstances).map(alertInstanceId => { - const alertInstance = alertInstances[alertInstanceId]; - if (alertInstance.hasScheduledActions()) { - if ( - alertInstance.isThrottled(throttle) || - muteAll || - mutedInstanceIds.includes(alertInstanceId) - ) { - return; - } - const { actionGroup, context, state } = alertInstance.getScheduledActionOptions()!; - alertInstance.updateLastScheduledActions(actionGroup); - alertInstance.unscheduleActions(); - return executionHandler({ actionGroup, context, state, alertInstanceId }); - } else { - // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object - delete alertInstances[alertInstanceId]; - } - }) - ); - - const nextRunAt = getNextRunAt( - new Date(taskInstance.startedAt!), - // we do not currently have a good way of returning the type - // from SavedObjectsClient, and as we currenrtly require a schedule - // and we only support `interval`, we can cast this safely - schedule as IntervalSchedule - ); - - return { - state: { - alertTypeState, - alertInstances, - previousStartedAt: taskInstance.startedAt!, - }, - runAt: nextRunAt, - }; - }, - }; - } -} diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index 24d4467dbd807..ede95f76bf811 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -9,7 +9,8 @@ import { first } from 'rxjs/operators'; import { Services } from './types'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry } from './alert_type_registry'; -import { AlertsClientFactory, TaskRunnerFactory } from './lib'; +import { TaskRunnerFactory } from './task_runner'; +import { AlertsClientFactory } from './alerts_client_factory'; import { LicenseState } from './lib/license_state'; import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; import { diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.ts b/x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/create_execution_handler.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/create_execution_handler.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts similarity index 92% rename from x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts index f9867b5372908..cea4584e1f713 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/get_next_run_at.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/get_next_run_at.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { parseDuration } from './parse_duration'; +import { parseDuration } from '../lib'; import { IntervalSchedule } from '../types'; export function getNextRunAt(currentRunAt: Date, schedule: IntervalSchedule) { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/index.ts b/x-pack/legacy/plugins/alerting/server/task_runner/index.ts new file mode 100644 index 0000000000000..f5401fbd9cd74 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { TaskRunnerFactory } from './task_runner_factory'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts new file mode 100644 index 0000000000000..10627c655eca8 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -0,0 +1,400 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon from 'sinon'; +import { schema } from '@kbn/config-schema'; +import { AlertExecutorOptions } from '../types'; +import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { TaskRunnerContext } from './task_runner_factory'; +import { TaskRunner } from './task_runner'; +import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { + savedObjectsClientMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; + +const alertType = { + id: 'test', + name: 'My test alert', + actionGroups: ['default'], + executor: jest.fn(), +}; +let fakeTimer: sinon.SinonFakeTimers; + +describe('Task Runner', () => { + let mockedTaskInstance: ConcreteTaskInstance; + + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + mockedTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + startedAt: new Date(Date.now() - 5 * 60 * 1000), + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + }); + + afterAll(() => fakeTimer.restore()); + + const savedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); + const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient, + }; + + const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = { + getServices: jest.fn().mockReturnValue(services), + executeAction: jest.fn(), + encryptedSavedObjectsPlugin, + logger: loggingServiceMock.create().get(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }; + + const mockedAlertTypeSavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + alertTypeId: '123', + schedule: { interval: '10s' }, + mutedInstanceIds: [], + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }; + + beforeEach(() => { + jest.resetAllMocks(); + taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + }); + + test('successfully executes the task', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "alertInstances": Object {}, + "alertTypeState": undefined, + "previousStartedAt": 1970-01-01T00:00:00.000Z, + }, + } + `); + expect(alertType.executor).toHaveBeenCalledTimes(1); + const call = alertType.executor.mock.calls[0][0]; + expect(call.params).toMatchInlineSnapshot(` + Object { + "bar": true, + } + `); + expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); + expect(call.state).toMatchInlineSnapshot(`Object {}`); + expect(call.services.alertInstanceFactory).toBeTruthy(); + expect(call.services.callCluster).toBeTruthy(); + expect(call.services).toBeTruthy(); + }); + + test('executeAction is called per alert instance that is scheduled', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + await taskRunner.run(); + expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1); + expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "id": "1", + "params": Object { + "foo": true, + }, + "spaceId": undefined, + }, + ] + `); + }); + + test('persists alertInstances passed in from state, only if they are scheduled for execution', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + executorServices.alertInstanceFactory('1').scheduleActions('default'); + } + ); + const taskRunner = new TaskRunner( + alertType, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + alertInstances: { + '1': { meta: {}, state: { bar: false } }, + '2': { meta: {}, state: { bar: false } }, + }, + }, + }, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + const runnerResult = await taskRunner.run(); + expect(runnerResult.state.alertInstances).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "lastScheduledActions": Object { + "date": 1970-01-01T00:00:00.000Z, + "group": "default", + }, + }, + "state": Object { + "bar": false, + }, + }, + } + `); + }); + + test('validates params before executing the alert type', async () => { + const taskRunner = new TaskRunner( + { + ...alertType, + validate: { + params: schema.object({ + param1: schema.string(), + }), + }, + }, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + expect(await taskRunner.run()).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( + `Executing Alert \"1\" has resulted in Error: params invalid: [param1]: expected value of type [string] but got [undefined]` + ); + }); + + test('throws error if reference not found', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce({ + ...mockedAlertTypeSavedObject, + references: [], + }); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + expect(await taskRunner.run()).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( + `Executing Alert \"1\" has resulted in Error: Action reference \"action_0\" not found in alert id: 1` + ); + }); + + test('uses API key when provided', async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + await taskRunner.run(); + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: { + // base64 encoded "123:abc" + authorization: 'ApiKey MTIzOmFiYw==', + }, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }); + }); + + test(`doesn't use API key when not provided`, async () => { + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: {}, + references: [], + }); + + await taskRunner.run(); + + expect(taskRunnerFactoryInitializerParams.getServices).toHaveBeenCalledWith({ + getBasePath: expect.anything(), + headers: {}, + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }); + }); + + test('recovers gracefully when the AlertType executor throws an exception', async () => { + alertType.executor.mockImplementation( + ({ services: executorServices }: AlertExecutorOptions) => { + throw new Error('OMG'); + } + ); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:00:10.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts new file mode 100644 index 0000000000000..2347e9e608ed9 --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick, mapValues, omit } from 'lodash'; +import { Logger } from '../../../../../../src/core/server'; +import { SavedObject } from '../../../../../../src/core/server'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ConcreteTaskInstance } from '../../../task_manager'; +import { createExecutionHandler } from './create_execution_handler'; +import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; +import { getNextRunAt } from './get_next_run_at'; +import { validateAlertTypeParams } from '../lib'; +import { AlertType, RawAlert, IntervalSchedule, Services, State } from '../types'; +import { promiseResult, map } from '../lib/result_type'; + +type AlertInstances = Record<string, AlertInstance>; + +export class TaskRunner { + private context: TaskRunnerContext; + private logger: Logger; + private taskInstance: ConcreteTaskInstance; + private alertType: AlertType; + + constructor( + alertType: AlertType, + taskInstance: ConcreteTaskInstance, + context: TaskRunnerContext + ) { + this.context = context; + this.logger = context.logger; + this.alertType = alertType; + this.taskInstance = taskInstance; + } + + async getApiKeyForAlertPermissions(alertId: string, spaceId: string) { + const namespace = this.context.spaceIdToNamespace(spaceId); + // Only fetch encrypted attributes here, we'll create a saved objects client + // scoped with the API key to fetch the remaining data. + const { + attributes: { apiKey }, + } = await this.context.encryptedSavedObjectsPlugin.getDecryptedAsInternalUser<RawAlert>( + 'alert', + alertId, + { namespace } + ); + + return apiKey; + } + + async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) { + const requestHeaders: Record<string, string> = {}; + + if (apiKey) { + requestHeaders.authorization = `ApiKey ${apiKey}`; + } + + const fakeRequest = { + headers: requestHeaders, + getBasePath: () => this.context.getBasePath(spaceId), + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + }; + + return this.context.getServices(fakeRequest); + } + + getExecutionHandler( + alertId: string, + spaceId: string, + apiKey: string | null, + actions: RawAlert['actions'], + references: SavedObject['references'] + ) { + // Inject ids into actions + const actionsWithIds = actions.map(action => { + const actionReference = references.find(obj => obj.name === action.actionRef); + if (!actionReference) { + throw new Error(`Action reference "${action.actionRef}" not found in alert id: ${alertId}`); + } + return { + ...action, + id: actionReference.id, + }; + }); + + return createExecutionHandler({ + alertId, + logger: this.logger, + executeAction: this.context.executeAction, + apiKey, + actions: actionsWithIds, + spaceId, + alertType: this.alertType, + }); + } + + async executeAlertInstance( + alertInstanceId: string, + alertInstance: AlertInstance, + executionHandler: ReturnType<typeof createExecutionHandler> + ) { + const { actionGroup, context, state } = alertInstance.getScheduledActionOptions()!; + alertInstance.updateLastScheduledActions(actionGroup); + alertInstance.unscheduleActions(); + return executionHandler({ actionGroup, context, state, alertInstanceId }); + } + + async executeAlertInstances( + services: Services, + { params, throttle, muteAll, mutedInstanceIds }: SavedObject['attributes'], + executionHandler: ReturnType<typeof createExecutionHandler> + ): Promise<State> { + const { + params: { alertId }, + state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, + } = this.taskInstance; + + const alertInstances = mapValues<AlertInstances>( + alertRawInstances, + alert => new AlertInstance(alert) + ); + + const updatedAlertTypeState = await this.alertType.executor({ + alertId, + services: { + ...services, + alertInstanceFactory: createAlertInstanceFactory(alertInstances), + }, + params, + state: alertTypeState, + startedAt: this.taskInstance.startedAt!, + previousStartedAt, + }); + + // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object + const instancesWithScheduledActions = pick<AlertInstances, AlertInstances>( + alertInstances, + alertInstance => alertInstance.hasScheduledActions() + ); + + if (!muteAll) { + const enabledAlertInstances = omit<AlertInstances, AlertInstances>( + instancesWithScheduledActions, + ...mutedInstanceIds + ); + + await Promise.all( + Object.entries(enabledAlertInstances) + .filter( + ([, alertInstance]: [string, AlertInstance]) => !alertInstance.isThrottled(throttle) + ) + .map(([id, alertInstance]: [string, AlertInstance]) => + this.executeAlertInstance(id, alertInstance, executionHandler) + ) + ); + } + + return { + alertTypeState: updatedAlertTypeState, + alertInstances: instancesWithScheduledActions, + }; + } + + async validateAndRunAlert( + services: Services, + apiKey: string | null, + attributes: SavedObject['attributes'], + references: SavedObject['references'] + ) { + const { + params: { alertId, spaceId }, + } = this.taskInstance; + + // Validate + const params = validateAlertTypeParams(this.alertType, attributes.params); + const executionHandler = this.getExecutionHandler( + alertId, + spaceId, + apiKey, + attributes.actions, + references + ); + return this.executeAlertInstances(services, { ...attributes, params }, executionHandler); + } + + async run() { + const { + params: { alertId, spaceId }, + startedAt: previousStartedAt, + state: originalState, + } = this.taskInstance; + + const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); + const services = await this.getServicesWithSpaceLevelPermissions(spaceId, apiKey); + + // Ensure API key is still valid and user has access + const { attributes, references } = await services.savedObjectsClient.get<RawAlert>( + 'alert', + alertId + ); + + return { + state: map<State, Error, State>( + await promiseResult<State, Error>( + this.validateAndRunAlert(services, apiKey, attributes, references) + ), + (stateUpdates: State) => { + return { + ...stateUpdates, + previousStartedAt, + }; + }, + (err: Error) => { + this.logger.error(`Executing Alert "${alertId}" has resulted in Error: ${err.message}`); + return { + ...originalState, + previousStartedAt, + }; + } + ), + runAt: getNextRunAt( + new Date(this.taskInstance.startedAt!), + // we do not currently have a good way of returning the type + // from SavedObjectsClient, and as we currenrtly require a schedule + // and we only support `interval`, we can cast this safely + attributes.schedule as IntervalSchedule + ), + }; + } +} diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts new file mode 100644 index 0000000000000..2ea1256352bec --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import sinon from 'sinon'; +import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; +import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; +import { + savedObjectsClientMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; + +const alertType = { + id: 'test', + name: 'My test alert', + actionGroups: ['default'], + executor: jest.fn(), +}; +let fakeTimer: sinon.SinonFakeTimers; + +describe('Task Runner Factory', () => { + let mockedTaskInstance: ConcreteTaskInstance; + + beforeAll(() => { + fakeTimer = sinon.useFakeTimers(); + mockedTaskInstance = { + id: '', + attempts: 0, + status: TaskStatus.Running, + version: '123', + runAt: new Date(), + scheduledAt: new Date(), + startedAt: new Date(), + retryAt: new Date(Date.now() + 5 * 60 * 1000), + state: { + startedAt: new Date(Date.now() - 5 * 60 * 1000), + }, + taskType: 'alerting:test', + params: { + alertId: '1', + }, + ownerId: null, + }; + }); + + afterAll(() => fakeTimer.restore()); + + const savedObjectsClient = savedObjectsClientMock.create(); + const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); + const services = { + log: jest.fn(), + callCluster: jest.fn(), + savedObjectsClient, + }; + + const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = { + getServices: jest.fn().mockReturnValue(services), + executeAction: jest.fn(), + encryptedSavedObjectsPlugin, + logger: loggingServiceMock.create().get(), + spaceIdToNamespace: jest.fn().mockReturnValue(undefined), + getBasePath: jest.fn().mockReturnValue(undefined), + }; + + beforeEach(() => { + jest.resetAllMocks(); + taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + }); + + test(`throws an error if factory isn't initialized`, () => { + const factory = new TaskRunnerFactory(); + expect(() => + factory.create(alertType, { taskInstance: mockedTaskInstance }) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); + }); + + test(`throws an error if factory is already initialized`, () => { + const factory = new TaskRunnerFactory(); + factory.initialize(taskRunnerFactoryInitializerParams); + expect(() => + factory.initialize(taskRunnerFactoryInitializerParams) + ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory already initialized"`); + }); +}); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts new file mode 100644 index 0000000000000..7186e1e729bda --- /dev/null +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from '../../../../../../src/core/server'; +import { RunContext } from '../../../task_manager'; +import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; +import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; +import { + AlertType, + GetBasePathFunction, + GetServicesFunction, + SpaceIdToNamespaceFunction, +} from '../types'; +import { TaskRunner } from './task_runner'; + +export interface TaskRunnerContext { + logger: Logger; + getServices: GetServicesFunction; + executeAction: ActionsPluginStartContract['execute']; + encryptedSavedObjectsPlugin: EncryptedSavedObjectsStartContract; + spaceIdToNamespace: SpaceIdToNamespaceFunction; + getBasePath: GetBasePathFunction; +} + +export class TaskRunnerFactory { + private isInitialized = false; + private taskRunnerContext?: TaskRunnerContext; + + public initialize(taskRunnerContext: TaskRunnerContext) { + if (this.isInitialized) { + throw new Error('TaskRunnerFactory already initialized'); + } + this.isInitialized = true; + this.taskRunnerContext = taskRunnerContext; + } + + public create(alertType: AlertType, { taskInstance }: RunContext) { + if (!this.isInitialized) { + throw new Error('TaskRunnerFactory not initialized'); + } + + return new TaskRunner(alertType, taskInstance, this.taskRunnerContext!); + } +} diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.test.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/transform_action_params.test.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.test.ts diff --git a/x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts b/x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.ts similarity index 100% rename from x-pack/legacy/plugins/alerting/server/lib/transform_action_params.ts rename to x-pack/legacy/plugins/alerting/server/task_runner/transform_action_params.ts diff --git a/x-pack/legacy/plugins/alerting/server/types.ts b/x-pack/legacy/plugins/alerting/server/types.ts index 62dcf07abb7bd..9b03f9b02aa0a 100644 --- a/x-pack/legacy/plugins/alerting/server/types.ts +++ b/x-pack/legacy/plugins/alerting/server/types.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertInstance } from './lib'; +import { AlertInstance } from './alert_instance'; import { AlertTypeRegistry as OrigAlertTypeRegistry } from './alert_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { SavedObjectAttributes, SavedObjectsClientContract } from '../../../../../src/core/server'; diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 487f396d7a3dc..c47649544f9a7 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -17,7 +17,7 @@ export interface AlertUtilsOpts { objectRemover?: ObjectRemover; } -export interface CreateAlwaysFiringActionOpts { +export interface CreateAlertWithActionOpts { indexRecordActionId?: string; objectRemover?: ObjectRemover; overwrites?: Record<string, any>; @@ -159,7 +159,7 @@ export class AlertUtils { overwrites = {}, indexRecordActionId, reference, - }: CreateAlwaysFiringActionOpts) { + }: CreateAlertWithActionOpts) { const objRemover = objectRemover || this.objectRemover; const actionId = indexRecordActionId || this.indexRecordActionId; @@ -207,4 +207,47 @@ export class AlertUtils { } return response; } + + public async createAlwaysFailingAction({ + objectRemover, + overwrites = {}, + indexRecordActionId, + reference, + }: CreateAlertWithActionOpts) { + const objRemover = objectRemover || this.objectRemover; + const actionId = indexRecordActionId || this.indexRecordActionId; + + if (!objRemover) { + throw new Error('objectRemover is required'); + } + if (!actionId) { + throw new Error('indexRecordActionId is required '); + } + + let request = this.supertestWithoutAuth + .post(`${getUrlPrefix(this.space.id)}/api/alert`) + .set('kbn-xsrf', 'foo'); + if (this.user) { + request = request.auth(this.user.username, this.user.password); + } + const response = await request.send({ + enabled: true, + name: 'fail', + schedule: { interval: '30s' }, + throttle: '30s', + tags: [], + alertTypeId: 'test.failing', + consumer: 'bar', + params: { + index: ES_TEST_INDEX_NAME, + reference, + }, + actions: [], + ...overwrites, + }); + if (response.statusCode === 200) { + objRemover.add(this.space.id, response.body.id, 'alert'); + } + return response; + } } diff --git a/x-pack/test/alerting_api_integration/common/lib/index.ts b/x-pack/test/alerting_api_integration/common/lib/index.ts index a2f21264634f8..c1e59664f9ce2 100644 --- a/x-pack/test/alerting_api_integration/common/lib/index.ts +++ b/x-pack/test/alerting_api_integration/common/lib/index.ts @@ -10,4 +10,5 @@ export { ES_TEST_INDEX_NAME, ESTestIndexTool } from './es_test_index_tool'; export { getTestAlertData } from './get_test_alert_data'; export { AlertUtils } from './alert_utils'; export { TaskManagerUtils } from './task_manager_utils'; +export * from './test_assertions'; export { checkAAD } from './check_aad'; diff --git a/x-pack/test/alerting_api_integration/common/lib/test_assertions.ts b/x-pack/test/alerting_api_integration/common/lib/test_assertions.ts new file mode 100644 index 0000000000000..9495dd4cfae82 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/lib/test_assertions.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export function ensureDatetimeIsWithinRange( + date: number, + expectedDiff: number, + buffer: number = 10000 +) { + const diff = date - Date.now(); + expect(diff).to.be.greaterThan(expectedDiff - buffer); + expect(diff).to.be.lessThan(expectedDiff + buffer); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index e89b54b1caa55..2a7e0b2203824 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -7,7 +7,13 @@ import expect from '@kbn/expect'; import { Response as SupertestResponse } from 'supertest'; import { UserAtSpaceScenarios } from '../../scenarios'; -import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { + checkAAD, + getUrlPrefix, + getTestAlertData, + ObjectRemover, + ensureDatetimeIsWithinRange, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -406,10 +412,3 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { } }); } - -function ensureDatetimeIsWithinRange(scheduledRunTime: number, expectedDiff: number) { - const buffer = 10000; - const diff = scheduledRunTime - Date.now(); - expect(diff).to.be.greaterThan(expectedDiff - buffer); - expect(diff).to.be.lessThan(expectedDiff + buffer); -} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts index 03e973194b4e2..032fee15882cf 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/alerts.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { Response as SupertestResponse } from 'supertest'; import { Spaces } from '../../scenarios'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { @@ -14,6 +15,7 @@ import { getTestAlertData, ObjectRemover, AlertUtils, + ensureDatetimeIsWithinRange, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -23,6 +25,13 @@ export default function alertTests({ getService }: FtrProviderContext) { const retry = getService('retry'); const esTestIndexTool = new ESTestIndexTool(es, retry); + function getAlertingTaskById(taskId: string) { + return supertestWithoutAuth + .get(`/api/alerting_tasks/${taskId}`) + .expect(200) + .then((response: SupertestResponse) => response.body); + } + describe('alerts', () => { let alertUtils: AlertUtils; let indexRecordActionId: string; @@ -100,6 +109,37 @@ export default function alertTests({ getService }: FtrProviderContext) { }); }); + it('should reschedule failing alerts using the alerting interval and not the Task Manager retry logic', async () => { + /* + Alerting does not use the Task Manager schedule and instead implements its own due to a current limitation + in TaskManager's ability to update an existing Task. + For this reason we need to handle the retry when Alert executors fail, as TaskManager doesn't understand that + alerting tasks are recurring tasks. + */ + const alertIntervalInSeconds = 30; + const reference = alertUtils.generateReference(); + const response = await alertUtils.createAlwaysFailingAction({ + reference, + overwrites: { schedule: { interval: `${alertIntervalInSeconds}s` } }, + }); + + expect(response.statusCode).to.eql(200); + + // wait for executor Alert Executor to be run, which means the underlying task is running + await esTestIndexTool.waitForDocs('alert:test.failing', reference); + + await retry.try(async () => { + const alertTask = (await getAlertingTaskById(response.body.scheduledTaskId)).docs[0]; + expect(alertTask.status).to.eql('idle'); + // ensure the alert is rescheduled to a minute from now + ensureDatetimeIsWithinRange( + Date.parse(alertTask.runAt), + alertIntervalInSeconds * 1000, + 5000 + ); + }); + }); + it('should handle custom retry logic', async () => { // We'll use this start time to query tasks created after this point const testStart = new Date(); From ff7782135ea31f8f23afddb78a4596403b5019c7 Mon Sep 17 00:00:00 2001 From: Nathan Reese <reese.nathan@gmail.com> Date: Mon, 6 Jan 2020 13:08:19 -0500 Subject: [PATCH 3/3] [Maps] Vector style UI redesign (#53946) (#54026) * [Maps] style editor update * update label editor * update size editor * update orienation editor * i18n cleanup * deconstruct props * review feedback Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../components/color/dynamic_color_form.js | 62 ++++++++ .../color/dynamic_color_selection.js | 48 ------ .../components/color/static_color_form.js | 33 ++++ .../color/static_color_selection.js | 30 ---- .../color/vector_style_color_editor.js | 34 ++-- .../components/label/dynamic_label_form.js | 37 +++++ .../label/dynamic_label_selector.js | 24 --- .../components/label/static_label_form.js | 34 ++++ .../components/label/static_label_selector.js | 28 ---- .../label/vector_style_label_editor.js | 18 +-- .../orientation/dynamic_orientation_form.js | 40 +++++ .../dynamic_orientation_selection.js | 32 ---- .../orientation/orientation_editor.js | 22 ++- .../orientation/static_orientation_form.js | 33 ++++ .../static_orientation_selection.js | 34 ---- .../components/size/dynamic_size_form.js | 63 ++++++++ .../components/size/dynamic_size_selection.js | 48 ------ .../components/size/static_size_form.js | 37 +++++ .../components/size/static_size_selection.js | 38 ----- .../size/vector_style_size_editor.js | 22 ++- .../components/static_dynamic_style_row.js | 145 ------------------ .../vector/components/style_prop_editor.js | 104 +++++++++++++ .../vector/components/vector_style_editor.js | 127 +++++++++++---- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 25 files changed, 585 insertions(+), 512 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_selection.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_selection.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_selector.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_selector.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_selection.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_selection.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_selection.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_selection.js delete mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/static_dynamic_style_row.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js new file mode 100644 index 0000000000000..5e0f7434b04d0 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_form.js @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { FieldSelect } from '../field_select'; +import { ColorRampSelect } from './color_ramp_select'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +export function DynamicColorForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + const onColorChange = colorOptions => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + ...colorOptions, + }); + }; + + let colorRampSelect; + if (styleOptions.field && styleOptions.field.name) { + colorRampSelect = ( + <ColorRampSelect + onChange={onColorChange} + color={styleOptions.color} + customColorRamp={styleOptions.customColorRamp} + useCustomColorRamp={_.get(styleOptions, 'useCustomColorRamp', false)} + compressed + /> + ); + } + + return ( + <Fragment> + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <FieldSelect + fields={fields} + selectedFieldName={_.get(styleOptions, 'field.name')} + onChange={onFieldChange} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + {colorRampSelect} + </Fragment> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_selection.js deleted file mode 100644 index 84327635f2b65..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/dynamic_color_selection.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { dynamicColorShape } from '../style_option_shapes'; -import { FieldSelect, fieldShape } from '../field_select'; -import { ColorRampSelect } from './color_ramp_select'; -import { EuiSpacer } from '@elastic/eui'; - -export function DynamicColorSelection({ fields, onChange, styleOptions }) { - const onFieldChange = ({ field }) => { - onChange({ ...styleOptions, field }); - }; - - const onColorChange = colorOptions => { - onChange({ ...styleOptions, ...colorOptions }); - }; - - return ( - <Fragment> - <ColorRampSelect - onChange={onColorChange} - color={styleOptions.color} - customColorRamp={styleOptions.customColorRamp} - useCustomColorRamp={_.get(styleOptions, 'useCustomColorRamp', false)} - compressed - /> - <EuiSpacer size="s" /> - <FieldSelect - fields={fields} - selectedFieldName={_.get(styleOptions, 'field.name')} - onChange={onFieldChange} - compressed - /> - </Fragment> - ); -} - -DynamicColorSelection.propTypes = { - fields: PropTypes.arrayOf(fieldShape).isRequired, - styleOptions: dynamicColorShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js new file mode 100644 index 0000000000000..48befa1ca74c0 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_form.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiColorPicker, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticColorForm({ + onStaticStyleChange, + staticDynamicSelect, + styleProperty, + swatches, +}) { + const onColorChange = color => { + onStaticStyleChange(styleProperty.getStyleName(), { color }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <EuiColorPicker + onChange={onColorChange} + color={styleProperty.getOptions().color} + swatches={swatches} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_selection.js deleted file mode 100644 index e42b582dc3929..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/static_color_selection.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { EuiColorPicker } from '@elastic/eui'; -import { staticColorShape } from '../style_option_shapes'; - -export function StaticColorSelection({ onChange, styleOptions, swatches }) { - const onColorChange = color => { - onChange({ color }); - }; - - return ( - <EuiColorPicker - onChange={onColorChange} - color={styleOptions.color} - swatches={swatches} - compressed - /> - ); -} - -StaticColorSelection.propTypes = { - styleOptions: staticColorShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js index c7745fa69a82f..43e7050b3d1d2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/color/vector_style_color_editor.js @@ -6,21 +6,29 @@ import React from 'react'; -import { StaticDynamicStyleRow } from '../static_dynamic_style_row'; -import { DynamicColorSelection } from './dynamic_color_selection'; -import { StaticColorSelection } from './static_color_selection'; +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicColorForm } from './dynamic_color_form'; +import { StaticColorForm } from './static_color_form'; +import { i18n } from '@kbn/i18n'; export function VectorStyleColorEditor(props) { + const colorForm = props.styleProperty.isDynamic() ? ( + <DynamicColorForm {...props} /> + ) : ( + <StaticColorForm {...props} /> + ); + return ( - <StaticDynamicStyleRow - fields={props.fields} - styleProperty={props.styleProperty} - handlePropertyChange={props.handlePropertyChange} - swatches={props.swatches} - DynamicSelector={DynamicColorSelection} - StaticSelector={StaticColorSelection} - defaultDynamicStyleOptions={props.defaultDynamicStyleOptions} - defaultStaticStyleOptions={props.defaultStaticStyleOptions} - /> + <StylePropEditor + {...props} + customStaticOptionLabel={i18n.translate( + 'xpack.maps.styles.color.staticDynamicSelect.staticLabel', + { + defaultMessage: 'Solid', + } + )} + > + {colorForm} + </StylePropEditor> ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js new file mode 100644 index 0000000000000..bad13b487cc29 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_form.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FieldSelect } from '../field_select'; + +export function DynamicLabelForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <FieldSelect + fields={fields} + selectedFieldName={_.get(styleOptions, 'field.name')} + onChange={onFieldChange} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_selector.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_selector.js deleted file mode 100644 index e393341b90696..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/dynamic_label_selector.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React from 'react'; -import { FieldSelect } from '../field_select'; - -export function DynamicLabelSelector({ fields, styleOptions, onChange }) { - const onFieldChange = ({ field }) => { - onChange({ ...styleOptions, field }); - }; - - return ( - <FieldSelect - fields={fields} - selectedFieldName={_.get(styleOptions, 'field.name')} - onChange={onFieldChange} - compressed - /> - ); -} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js new file mode 100644 index 0000000000000..721487b5d8ff0 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_form.js @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticLabelForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onValueChange = event => { + onStaticStyleChange(styleProperty.getStyleName(), { value: event.target.value }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <EuiFieldText + placeholder={i18n.translate('xpack.maps.styles.staticLabel.valuePlaceholder', { + defaultMessage: 'symbol label', + })} + value={styleProperty.getOptions().value} + onChange={onValueChange} + aria-label={i18n.translate('xpack.maps.styles.staticLabel.valueAriaLabel', { + defaultMessage: 'symbol label', + })} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_selector.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_selector.js deleted file mode 100644 index ea296a3312799..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/static_label_selector.js +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiFieldText } from '@elastic/eui'; - -export function StaticLabelSelector({ onChange, styleOptions }) { - const onValueChange = event => { - onChange({ value: event.target.value }); - }; - - return ( - <EuiFieldText - placeholder={i18n.translate('xpack.maps.styles.staticLabel.valuePlaceholder', { - defaultMessage: 'symbol label', - })} - value={styleOptions.value} - onChange={onValueChange} - aria-label={i18n.translate('xpack.maps.styles.staticLabel.valueAriaLabel', { - defaultMessage: 'symbol label', - })} - /> - ); -} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js index 6bca56425d38d..aaa21ea315f36 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_editor.js @@ -6,16 +6,16 @@ import React from 'react'; -import { StaticDynamicStyleRow } from '../static_dynamic_style_row'; -import { DynamicLabelSelector } from './dynamic_label_selector'; -import { StaticLabelSelector } from './static_label_selector'; +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicLabelForm } from './dynamic_label_form'; +import { StaticLabelForm } from './static_label_form'; export function VectorStyleLabelEditor(props) { - return ( - <StaticDynamicStyleRow - {...props} - DynamicSelector={DynamicLabelSelector} - StaticSelector={StaticLabelSelector} - /> + const labelForm = props.styleProperty.isDynamic() ? ( + <DynamicLabelForm {...props} /> + ) : ( + <StaticLabelForm {...props} /> ); + + return <StylePropEditor {...props}>{labelForm}</StylePropEditor>; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js new file mode 100644 index 0000000000000..e0b7e7b2865a2 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_form.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React from 'react'; +import { FieldSelect } from '../field_select'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function DynamicOrientationForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + field, + }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <FieldSelect + fields={fields} + selectedFieldName={_.get(styleOptions, 'field.name')} + onChange={onFieldChange} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_selection.js deleted file mode 100644 index 8ad3916ac6509..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/dynamic_orientation_selection.js +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React from 'react'; -import PropTypes from 'prop-types'; -import { dynamicOrientationShape } from '../style_option_shapes'; -import { FieldSelect, fieldShape } from '../field_select'; - -export function DynamicOrientationSelection({ fields, styleOptions, onChange }) { - const onFieldChange = ({ field }) => { - onChange({ ...styleOptions, field }); - }; - - return ( - <FieldSelect - fields={fields} - selectedFieldName={_.get(styleOptions, 'field.name')} - onChange={onFieldChange} - compressed - /> - ); -} - -DynamicOrientationSelection.propTypes = { - fields: PropTypes.arrayOf(fieldShape).isRequired, - styleOptions: dynamicOrientationShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js index e97252a5e79da..915fc92c9fb38 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/orientation_editor.js @@ -6,20 +6,16 @@ import React from 'react'; -import { StaticDynamicStyleRow } from '../static_dynamic_style_row'; -import { DynamicOrientationSelection } from './dynamic_orientation_selection'; -import { StaticOrientationSelection } from './static_orientation_selection'; +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicOrientationForm } from './dynamic_orientation_form'; +import { StaticOrientationForm } from './static_orientation_form'; export function OrientationEditor(props) { - return ( - <StaticDynamicStyleRow - fields={props.fields} - styleProperty={props.styleProperty} - handlePropertyChange={props.handlePropertyChange} - DynamicSelector={DynamicOrientationSelection} - StaticSelector={StaticOrientationSelection} - defaultDynamicStyleOptions={props.defaultDynamicStyleOptions} - defaultStaticStyleOptions={props.defaultStaticStyleOptions} - /> + const orientationForm = props.styleProperty.isDynamic() ? ( + <DynamicOrientationForm {...props} /> + ) : ( + <StaticOrientationForm {...props} /> ); + + return <StylePropEditor {...props}>{orientationForm}</StylePropEditor>; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js new file mode 100644 index 0000000000000..8c4418f95e1d2 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_form.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ValidatedRange } from '../../../../../components/validated_range'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticOrientationForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onOrientationChange = orientation => { + onStaticStyleChange(styleProperty.getStyleName(), { orientation }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <ValidatedRange + min={0} + max={360} + value={styleProperty.getOptions().orientation} + onChange={onOrientationChange} + showInput="inputWithPopover" + showLabels + compressed + append="°" + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_selection.js deleted file mode 100644 index b5529c6987459..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/orientation/static_orientation_selection.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { staticOrientationShape } from '../style_option_shapes'; -import { ValidatedRange } from '../../../../../components/validated_range'; - -export function StaticOrientationSelection({ onChange, styleOptions }) { - const onOrientationChange = orientation => { - onChange({ orientation }); - }; - - return ( - <ValidatedRange - min={0} - max={360} - value={styleOptions.orientation} - onChange={onOrientationChange} - showInput - showLabels - compressed - append="°" - /> - ); -} - -StaticOrientationSelection.propTypes = { - styleOptions: staticOrientationShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js new file mode 100644 index 0000000000000..8b069cd53b731 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_form.js @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import React, { Fragment } from 'react'; +import { FieldSelect } from '../field_select'; +import { SizeRangeSelector } from './size_range_selector'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; + +export function DynamicSizeForm({ + fields, + onDynamicStyleChange, + staticDynamicSelect, + styleProperty, +}) { + const styleOptions = styleProperty.getOptions(); + + const onFieldChange = ({ field }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); + }; + + const onSizeRangeChange = ({ minSize, maxSize }) => { + onDynamicStyleChange(styleProperty.getStyleName(), { + ...styleOptions, + minSize, + maxSize, + }); + }; + + let sizeRange; + if (styleOptions.field && styleOptions.field.name) { + sizeRange = ( + <SizeRangeSelector + onChange={onSizeRangeChange} + minSize={styleOptions.minSize} + maxSize={styleOptions.maxSize} + showLabels + compressed + /> + ); + } + + return ( + <Fragment> + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <FieldSelect + fields={fields} + selectedFieldName={_.get(styleOptions, 'field.name')} + onChange={onFieldChange} + compressed + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + {sizeRange} + </Fragment> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_selection.js deleted file mode 100644 index 76c5b97976bbc..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/dynamic_size_selection.js +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import _ from 'lodash'; -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { dynamicSizeShape } from '../style_option_shapes'; -import { FieldSelect, fieldShape } from '../field_select'; -import { SizeRangeSelector } from './size_range_selector'; -import { EuiSpacer } from '@elastic/eui'; - -export function DynamicSizeSelection({ fields, styleOptions, onChange }) { - const onFieldChange = ({ field }) => { - onChange({ ...styleOptions, field }); - }; - - const onSizeRangeChange = ({ minSize, maxSize }) => { - onChange({ ...styleOptions, minSize, maxSize }); - }; - - return ( - <Fragment> - <SizeRangeSelector - onChange={onSizeRangeChange} - minSize={styleOptions.minSize} - maxSize={styleOptions.maxSize} - showLabels - compressed - /> - <EuiSpacer size="s" /> - <FieldSelect - fields={fields} - selectedFieldName={_.get(styleOptions, 'field.name')} - onChange={onFieldChange} - compressed - /> - </Fragment> - ); -} - -DynamicSizeSelection.propTypes = { - fields: PropTypes.arrayOf(fieldShape).isRequired, - styleOptions: dynamicSizeShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js new file mode 100644 index 0000000000000..d8fe1322db3e3 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_form.js @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ValidatedRange } from '../../../../../components/validated_range'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +export function StaticSizeForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { + const onSizeChange = size => { + onStaticStyleChange(styleProperty.getStyleName(), { size }); + }; + + return ( + <EuiFlexGroup gutterSize="none" justifyContent="flexEnd"> + <EuiFlexItem grow={false}>{staticDynamicSelect}</EuiFlexItem> + <EuiFlexItem> + <ValidatedRange + min={0} + max={100} + value={styleProperty.getOptions().size} + onChange={onSizeChange} + showInput="inputWithPopover" + showLabels + compressed + append={i18n.translate('xpack.maps.vector.size.unitLabel', { + defaultMessage: 'px', + description: 'Shorthand for pixel', + })} + /> + </EuiFlexItem> + </EuiFlexGroup> + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_selection.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_selection.js deleted file mode 100644 index 38f8fe53d1748..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/static_size_selection.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { staticSizeShape } from '../style_option_shapes'; -import { ValidatedRange } from '../../../../../components/validated_range'; -import { i18n } from '@kbn/i18n'; - -export function StaticSizeSelection({ onChange, styleOptions }) { - const onSizeChange = size => { - onChange({ size }); - }; - - return ( - <ValidatedRange - min={0} - max={100} - value={styleOptions.size} - onChange={onSizeChange} - showInput - showLabels - compressed - append={i18n.translate('xpack.maps.vector.size.unitLabel', { - defaultMessage: 'px', - description: 'Shorthand for pixel', - })} - /> - ); -} - -StaticSizeSelection.propTypes = { - styleOptions: staticSizeShape.isRequired, - onChange: PropTypes.func.isRequired, -}; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js index 6580bfc00e0ad..e344f72bd429a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/size/vector_style_size_editor.js @@ -6,20 +6,16 @@ import React from 'react'; -import { StaticDynamicStyleRow } from '../static_dynamic_style_row'; -import { DynamicSizeSelection } from './dynamic_size_selection'; -import { StaticSizeSelection } from './static_size_selection'; +import { StylePropEditor } from '../style_prop_editor'; +import { DynamicSizeForm } from './dynamic_size_form'; +import { StaticSizeForm } from './static_size_form'; export function VectorStyleSizeEditor(props) { - return ( - <StaticDynamicStyleRow - fields={props.fields} - styleProperty={props.styleProperty} - handlePropertyChange={props.handlePropertyChange} - DynamicSelector={DynamicSizeSelection} - StaticSelector={StaticSizeSelection} - defaultDynamicStyleOptions={props.defaultDynamicStyleOptions} - defaultStaticStyleOptions={props.defaultStaticStyleOptions} - /> + const sizeForm = props.styleProperty.isDynamic() ? ( + <DynamicSizeForm {...props} /> + ) : ( + <StaticSizeForm {...props} /> ); + + return <StylePropEditor {...props}>{sizeForm}</StylePropEditor>; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/static_dynamic_style_row.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/static_dynamic_style_row.js deleted file mode 100644 index 311406731801a..0000000000000 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/static_dynamic_style_row.js +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Component, Fragment } from 'react'; -import { VectorStyle } from '../vector_style'; -import { i18n } from '@kbn/i18n'; -import { FieldMetaOptionsPopover } from './field_meta_options_popover'; -import { getVectorStyleLabel } from './get_vector_style_label'; - -import { EuiFlexGroup, EuiFlexItem, EuiToolTip, EuiFormRow, EuiButtonToggle } from '@elastic/eui'; - -export class StaticDynamicStyleRow extends Component { - // Store previous options locally so when type is toggled, - // previous style options can be used. - prevStaticStyleOptions = this.props.defaultStaticStyleOptions; - prevDynamicStyleOptions = this.props.defaultDynamicStyleOptions; - - _canBeDynamic() { - return this.props.fields.length > 0; - } - - _isDynamic() { - return this.props.styleProperty.isDynamic(); - } - - _getStyleOptions() { - return this.props.styleProperty.getOptions(); - } - - _onFieldMetaOptionsChange = fieldMetaOptions => { - const styleDescriptor = { - type: VectorStyle.STYLE_TYPE.DYNAMIC, - options: { - ...this._getStyleOptions(), - fieldMetaOptions, - }, - }; - this.props.handlePropertyChange(this.props.styleProperty.getStyleName(), styleDescriptor); - }; - - _onStaticStyleChange = options => { - const styleDescriptor = { - type: VectorStyle.STYLE_TYPE.STATIC, - options, - }; - this.props.handlePropertyChange(this.props.styleProperty.getStyleName(), styleDescriptor); - }; - - _onDynamicStyleChange = options => { - const styleDescriptor = { - type: VectorStyle.STYLE_TYPE.DYNAMIC, - options, - }; - this.props.handlePropertyChange(this.props.styleProperty.getStyleName(), styleDescriptor); - }; - - _onTypeToggle = () => { - if (this._isDynamic()) { - // preserve current dynmaic style - this.prevDynamicStyleOptions = this._getStyleOptions(); - // toggle to static style - this._onStaticStyleChange(this.prevStaticStyleOptions); - return; - } - - // preserve current static style - this.prevStaticStyleOptions = this._getStyleOptions(); - // toggle to dynamic style - this._onDynamicStyleChange(this.prevDynamicStyleOptions); - }; - - _renderStyleSelector() { - if (this._isDynamic()) { - const DynamicSelector = this.props.DynamicSelector; - return ( - <Fragment> - <DynamicSelector - fields={this.props.fields} - onChange={this._onDynamicStyleChange} - styleOptions={this._getStyleOptions()} - /> - <FieldMetaOptionsPopover - styleProperty={this.props.styleProperty} - onChange={this._onFieldMetaOptionsChange} - /> - </Fragment> - ); - } - - const StaticSelector = this.props.StaticSelector; - return ( - <StaticSelector - onChange={this._onStaticStyleChange} - styleOptions={this._getStyleOptions()} - swatches={this.props.swatches} - /> - ); - } - - render() { - const isDynamic = this._isDynamic(); - const dynamicTooltipContent = isDynamic - ? i18n.translate('xpack.maps.styles.staticDynamic.staticDescription', { - defaultMessage: 'Use static styling properties to symbolize features.', - }) - : i18n.translate('xpack.maps.styles.staticDynamic.dynamicDescription', { - defaultMessage: 'Use property values to symbolize features.', - }); - - return ( - <EuiFlexGroup gutterSize="xs"> - <EuiFlexItem - className={isDynamic ? 'mapStaticDynamicSylingOption__dynamicSizeHack' : undefined} - > - <EuiFormRow - label={getVectorStyleLabel(this.props.styleProperty.getStyleName())} - display="rowCompressed" - > - {this._renderStyleSelector()} - </EuiFormRow> - </EuiFlexItem> - {this._canBeDynamic() && ( - <EuiFlexItem grow={false}> - <EuiFormRow hasEmptyLabelSpace display="centerCompressed"> - <EuiToolTip content={dynamicTooltipContent} delay="long"> - <EuiButtonToggle - size="s" - label={dynamicTooltipContent} - iconType="link" - onChange={this._onTypeToggle} - isEmpty={!isDynamic} - fill={isDynamic} - isIconOnly - /> - </EuiToolTip> - </EuiFormRow> - </EuiFlexItem> - )} - </EuiFlexGroup> - ); - } -} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js new file mode 100644 index 0000000000000..1ac8edfb2cc69 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/style_prop_editor.js @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { FieldMetaOptionsPopover } from './field_meta_options_popover'; +import { getVectorStyleLabel } from './get_vector_style_label'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { VectorStyle } from '../vector_style'; +import { i18n } from '@kbn/i18n'; + +export class StylePropEditor extends Component { + _prevStaticStyleOptions = this.props.defaultStaticStyleOptions; + _prevDynamicStyleOptions = this.props.defaultDynamicStyleOptions; + + _onTypeToggle = () => { + if (this.props.styleProperty.isDynamic()) { + // preserve current dynmaic style + this._prevDynamicStyleOptions = this.props.styleProperty.getOptions(); + // toggle to static style + this.props.onStaticStyleChange( + this.props.styleProperty.getStyleName(), + this._prevStaticStyleOptions + ); + } else { + // preserve current static style + this._prevStaticStyleOptions = this.props.styleProperty.getOptions(); + // toggle to dynamic style + this.props.onDynamicStyleChange( + this.props.styleProperty.getStyleName(), + this._prevDynamicStyleOptions + ); + } + }; + + _onFieldMetaOptionsChange = fieldMetaOptions => { + const options = { + ...this.props.styleProperty.getOptions(), + fieldMetaOptions, + }; + this.props.onDynamicStyleChange(this.props.styleProperty.getStyleName(), options); + }; + + renderStaticDynamicSelect() { + const options = [ + { + value: VectorStyle.STYLE_TYPE.STATIC, + text: this.props.customStaticOptionLabel + ? this.props.customStaticOptionLabel + : i18n.translate('xpack.maps.styles.staticDynamicSelect.staticLabel', { + defaultMessage: 'Fixed', + }), + }, + { + value: VectorStyle.STYLE_TYPE.DYNAMIC, + text: i18n.translate('xpack.maps.styles.staticDynamicSelect.dynamicLabel', { + defaultMessage: 'By value', + }), + }, + ]; + + return ( + <EuiSelect + options={options} + value={ + this.props.styleProperty.isDynamic() + ? VectorStyle.STYLE_TYPE.DYNAMIC + : VectorStyle.STYLE_TYPE.STATIC + } + onChange={this._onTypeToggle} + disabled={this.props.fields.length === 0} + aria-label={i18n.translate('xpack.maps.styles.staticDynamicSelect.ariaLabel', { + defaultMessage: 'Select to style by fixed value or by data value', + })} + compressed + /> + ); + } + + render() { + const fieldMetaOptionsPopover = this.props.styleProperty.isDynamic() ? ( + <FieldMetaOptionsPopover + styleProperty={this.props.styleProperty} + onChange={this._onFieldMetaOptionsChange} + /> + ) : null; + + return ( + <EuiFormRow + label={getVectorStyleLabel(this.props.styleProperty.getStyleName())} + display="rowCompressed" + > + <Fragment> + {React.cloneElement(this.props.children, { + staticDynamicSelect: this.renderStaticDynamicSelect(), + })} + {fieldMetaOptionsPopover} + </Fragment> + </EuiFormRow> + ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 44f630db9d890..8e80e036dbb8b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -12,8 +12,13 @@ import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; import { VectorStyleSymbolEditor } from './vector_style_symbol_editor'; import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; +import { VectorStyle } from '../vector_style'; import { OrientationEditor } from './orientation/orientation_editor'; -import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; +import { + getDefaultDynamicProperties, + getDefaultStaticProperties, + VECTOR_STYLES, +} from '../vector_style_defaults'; import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; import { VECTOR_SHAPE_TYPES } from '../../../sources/vector_feature_types'; import { SYMBOLIZE_AS_ICON } from '../vector_constants'; @@ -128,15 +133,36 @@ export class VectorStyleEditor extends Component { this.props.onIsTimeAwareChange(event.target.checked); }; + _onStaticStyleChange = (propertyName, options) => { + const styleDescriptor = { + type: VectorStyle.STYLE_TYPE.STATIC, + options, + }; + this.props.handlePropertyChange(propertyName, styleDescriptor); + }; + + _onDynamicStyleChange = (propertyName, options) => { + const styleDescriptor = { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options, + }; + this.props.handlePropertyChange(propertyName, styleDescriptor); + }; + _renderFillColor() { return ( <VectorStyleColorEditor swatches={DEFAULT_FILL_COLORS} - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.fillColor} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.FILL_COLOR]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.fillColor.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.fillColor.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.FILL_COLOR].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.FILL_COLOR].options + } /> ); } @@ -145,11 +171,16 @@ export class VectorStyleEditor extends Component { return ( <VectorStyleColorEditor swatches={DEFAULT_LINE_COLORS} - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.lineColor} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.LINE_COLOR]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.lineColor.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.lineColor.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.LINE_COLOR].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.LINE_COLOR].options + } /> ); } @@ -157,11 +188,16 @@ export class VectorStyleEditor extends Component { _renderLineWidth() { return ( <VectorStyleSizeEditor - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.lineWidth} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.LINE_WIDTH]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.lineWidth.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.lineWidth.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.LINE_WIDTH].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.LINE_WIDTH].options + } /> ); } @@ -169,11 +205,16 @@ export class VectorStyleEditor extends Component { _renderSymbolSize() { return ( <VectorStyleSizeEditor - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.iconSize} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.ICON_SIZE]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.iconSize.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.iconSize.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.ICON_SIZE].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.ICON_SIZE].options + } /> ); } @@ -182,30 +223,45 @@ export class VectorStyleEditor extends Component { return ( <Fragment> <VectorStyleLabelEditor - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.labelText} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_TEXT]} fields={this.state.fields} - defaultStaticStyleOptions={this.state.defaultStaticProperties.labelText.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.labelText.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_TEXT].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.LABEL_TEXT].options + } /> <EuiSpacer size="m" /> <VectorStyleColorEditor swatches={DEFAULT_LINE_COLORS} - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.labelColor} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_COLOR]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.labelColor.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.labelColor.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_COLOR].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.LABEL_COLOR].options + } /> <EuiSpacer size="m" /> <VectorStyleSizeEditor - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.labelSize} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.LABEL_SIZE]} fields={this._getOrdinalFields()} - defaultStaticStyleOptions={this.state.defaultStaticProperties.labelSize.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.labelSize.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.LABEL_SIZE].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.LABEL_SIZE].options + } /> <EuiSpacer size="m" /> </Fragment> @@ -217,11 +273,16 @@ export class VectorStyleEditor extends Component { if (this.props.symbolDescriptor.options.symbolizeAs === SYMBOLIZE_AS_ICON) { iconOrientation = ( <OrientationEditor - handlePropertyChange={this.props.handlePropertyChange} - styleProperty={this.props.styleProperties.iconOrientation} + onStaticStyleChange={this._onStaticStyleChange} + onDynamicStyleChange={this._onDynamicStyleChange} + styleProperty={this.props.styleProperties[VECTOR_STYLES.ICON_ORIENTATION]} fields={this.state.numberFields} - defaultStaticStyleOptions={this.state.defaultStaticProperties.iconOrientation.options} - defaultDynamicStyleOptions={this.state.defaultDynamicProperties.iconOrientation.options} + defaultStaticStyleOptions={ + this.state.defaultStaticProperties[VECTOR_STYLES.ICON_ORIENTATION].options + } + defaultDynamicStyleOptions={ + this.state.defaultDynamicProperties[VECTOR_STYLES.ICON_ORIENTATION].options + } /> ); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cd6286afe7831..8bb3902a34c10 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6599,8 +6599,6 @@ "xpack.maps.source.wmsTitle": "ウェブマップサービス", "xpack.maps.style.heatmap.displayNameLabel": "ヒートマップスタイル", "xpack.maps.style.heatmap.resolutionStyleErrorMessage": "解像度パラメーターが認識されません: {resolution}", - "xpack.maps.styles.staticDynamic.dynamicDescription": "プロパティ値で特徴をシンボル化します。", - "xpack.maps.styles.staticDynamic.staticDescription": "静的スタイルプロパティで特徴をシンボル化します。", "xpack.maps.styles.vector.borderColorLabel": "境界線の色", "xpack.maps.styles.vector.borderWidthLabel": "境界線の幅", "xpack.maps.styles.vector.fillColorLabel": "塗りつぶす色", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 749306fa9e8a2..8c58c07c03f22 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6540,8 +6540,6 @@ "xpack.maps.source.wmsTitle": "Web 地图服务", "xpack.maps.style.heatmap.displayNameLabel": "热图样式", "xpack.maps.style.heatmap.resolutionStyleErrorMessage": "无法识别分辨率参数:{resolution}", - "xpack.maps.styles.staticDynamic.dynamicDescription": "使用属性值代表功能。", - "xpack.maps.styles.staticDynamic.staticDescription": "使用静态样式属性代表功能。", "xpack.maps.styles.vector.borderColorLabel": "边框颜色", "xpack.maps.styles.vector.borderWidthLabel": "边框宽度", "xpack.maps.styles.vector.fillColorLabel": "填充颜色",