From ef86fbc7802008f3a9cee8ef609d964749d15e3a Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 22 Sep 2020 15:31:07 +0200 Subject: [PATCH 01/15] call .destroy on ace when react component unmounts (#78132) --- .../containers/editor/legacy/console_editor/editor.tsx | 3 +++ .../models/legacy_core_editor/legacy_core_editor.ts | 4 ++++ src/plugins/console/public/types/core_editor.ts | 5 +++++ .../searchprofiler/public/application/editor/editor.tsx | 6 ++++++ 4 files changed, 18 insertions(+) diff --git a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx index fc88b31711b23c..abef8afcc39857 100644 --- a/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/plugins/console/public/application/containers/editor/legacy/console_editor/editor.tsx @@ -182,6 +182,9 @@ function EditorUI({ initialTextValue }: EditorProps) { unsubscribeResizer(); clearSubscriptions(); window.removeEventListener('hashchange', onHashChange); + if (editorInstanceRef.current) { + editorInstanceRef.current.getCoreEditor().destroy(); + } }; }, [saveCurrentTextObject, initialTextValue, history, setInputEditor, settingsService]); diff --git a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts index 469ef6d79fae57..393b7eee346f5f 100644 --- a/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/plugins/console/public/application/models/legacy_core_editor/legacy_core_editor.ts @@ -408,4 +408,8 @@ export class LegacyCoreEditor implements CoreEditor { }, ]); } + + destroy() { + this.editor.destroy(); + } } diff --git a/src/plugins/console/public/types/core_editor.ts b/src/plugins/console/public/types/core_editor.ts index b71f4fff44ca5f..d88d8f86b874cb 100644 --- a/src/plugins/console/public/types/core_editor.ts +++ b/src/plugins/console/public/types/core_editor.ts @@ -268,4 +268,9 @@ export interface CoreEditor { * detects a change */ registerAutocompleter(autocompleter: AutoCompleterFunction): void; + + /** + * Release any resources in use by the editor. + */ + destroy(): void; } diff --git a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx index 3141f5bedc8f9c..7e7d74155b2d91 100644 --- a/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx +++ b/x-pack/plugins/searchprofiler/public/application/editor/editor.tsx @@ -56,6 +56,12 @@ export const Editor = memo(({ licenseEnabled, initialValue, onEditorReady }: Pro setTextArea(licenseEnabled ? containerRef.current!.querySelector('textarea') : null); onEditorReady(createEditorShim(editorInstanceRef.current)); + + return () => { + if (editorInstanceRef.current) { + editorInstanceRef.current.destroy(); + } + }; }, [initialValue, onEditorReady, licenseEnabled]); return ( From 3f5243eefae435532b25f4dcf8bdac29f2244944 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Tue, 22 Sep 2020 10:15:27 -0400 Subject: [PATCH 02/15] [Alerting] optimize calculation of unmuted alert instances (#78021) This PR optimizes the calculation of instances which should be executed, by optimizing the way the muted instances are removed from the collection of triggered instances. --- x-pack/plugins/alerts/server/task_runner/task_runner.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 5be684eca4651a..7ea3f83d747c0c 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pickBy, mapValues, omit, without } from 'lodash'; +import { pickBy, mapValues, without } from 'lodash'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../task_manager/server'; @@ -228,12 +228,13 @@ export class TaskRunner { }); if (!muteAll) { - const enabledAlertInstances = omit(instancesWithScheduledActions, ...mutedInstanceIds); + const mutedInstanceIdsSet = new Set(mutedInstanceIds); await Promise.all( - Object.entries(enabledAlertInstances) + Object.entries(instancesWithScheduledActions) .filter( - ([, alertInstance]: [string, AlertInstance]) => !alertInstance.isThrottled(throttle) + ([alertInstanceName, alertInstance]: [string, AlertInstance]) => + !alertInstance.isThrottled(throttle) && !mutedInstanceIdsSet.has(alertInstanceName) ) .map(([id, alertInstance]: [string, AlertInstance]) => this.executeAlertInstance(id, alertInstance, executionHandler) From a49b99011515f10e8d1e788bbd16d037a47428cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 22 Sep 2020 15:17:40 +0100 Subject: [PATCH 03/15] [Enterprise Search] Rename "telemetry" to "stats" (#78124) --- .../applications/shared/telemetry/send_telemetry.test.tsx | 8 ++++---- .../applications/shared/telemetry/send_telemetry.tsx | 2 +- .../server/routes/enterprise_search/telemetry.test.ts | 2 +- .../server/routes/enterprise_search/telemetry.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 8f7cf090e2d573..1d64b453b2c2c3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -33,7 +33,7 @@ describe('Shared Telemetry Helpers', () => { metric: 'setup_guide', }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); @@ -54,7 +54,7 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"page"}', }); @@ -65,7 +65,7 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"app_search","action":"clicked","metric":"button"}', }); @@ -76,7 +76,7 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"workplace_search","action":"error","metric":"not_found"}', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 4df1428221de61..e3c9ba9b8a218f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -27,7 +27,7 @@ interface ISendTelemetry extends ISendTelemetryProps { export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { try { const body = JSON.stringify({ product, action, metric }); - await http.put('/api/enterprise_search/telemetry', { headers, body }); + await http.put('/api/enterprise_search/stats', { headers, body }); } catch (error) { throw new Error('Unable to send telemetry'); } diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index acddd3539965a7..bd6f4b9da91fd6 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -35,7 +35,7 @@ describe('Enterprise Search Telemetry API', () => { }); }); - describe('PUT /api/enterprise_search/telemetry', () => { + describe('PUT /api/enterprise_search/stats', () => { it('increments the saved objects counter for App Search', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index bfc07c8b64ef50..8f6638ddc099ef 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -25,7 +25,7 @@ export function registerTelemetryRoute({ }: IRouteDependencies) { router.put( { - path: '/api/enterprise_search/telemetry', + path: '/api/enterprise_search/stats', validate: { body: schema.object({ product: schema.oneOf([ From 037eac55902de5d1e40d3372e83897384f4e95b0 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 22 Sep 2020 10:14:31 -0500 Subject: [PATCH 04/15] Remove service map beta badge (#78039) Fixes #60529. --- docs/apm/service-maps.asciidoc | 5 --- .../components/app/ServiceMap/BetaBadge.tsx | 36 ------------------- .../components/app/ServiceMap/index.tsx | 10 +++--- .../translations/translations/ja-JP.json | 2 -- .../translations/translations/zh-CN.json | 2 -- 5 files changed, 4 insertions(+), 51 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index db2f85c54c7624..d629a95073a745 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -2,11 +2,6 @@ [[service-maps]] === Service maps -beta::[] - -WARNING: Service map support for Internet Explorer 11 is extremely limited. -Please use Chrome or Firefox if available. - A service map is a real-time visual representation of the instrumented services in your application's architecture. It shows you how these services are connected, along with high-level metrics like average transaction duration, requests per minute, and errors per minute. diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx deleted file mode 100644 index b468470e3a17d8..00000000000000 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/BetaBadge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiBetaBadge } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import styled from 'styled-components'; - -const BetaBadgeContainer = styled.div` - right: ${({ theme }) => theme.eui.gutterTypes.gutterMedium}; - position: absolute; - top: ${({ theme }) => theme.eui.gutterTypes.gutterSmall}; - z-index: 1; /* The element containing the cytoscape canvas has z-index = 0. */ -`; - -export function BetaBadge() { - return ( - - - - ); -} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx index cb5a57e9ab9fba..bb450131bdfb88 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,15 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useTheme } from '../../../hooks/useTheme'; +import React from 'react'; +import { useTrackPageview } from '../../../../../observability/public'; import { invalidLicenseMessage, isActivePlatinumLicense, } from '../../../../common/service_map'; import { useFetcher } from '../../../hooks/useFetcher'; import { useLicense } from '../../../hooks/useLicense'; +import { useTheme } from '../../../hooks/useTheme'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; import { LicensePrompt } from '../../shared/LicensePrompt'; @@ -22,8 +23,6 @@ import { getCytoscapeDivStyle } from './cytoscapeOptions'; import { EmptyBanner } from './EmptyBanner'; import { Popover } from './Popover'; import { useRefDimensions } from './useRefDimensions'; -import { BetaBadge } from './BetaBadge'; -import { useTrackPageview } from '../../../../../observability/public'; interface ServiceMapProps { serviceName?: string; @@ -80,7 +79,6 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { style={getCytoscapeDivStyle(theme)} > - {serviceName && } @@ -96,7 +94,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) { grow={false} style={{ width: 600, textAlign: 'center' as const }} > - + ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b86d59762c8b87..f626835da8e11e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4806,8 +4806,6 @@ "xpack.apm.serviceMap.avgMemoryUsagePopoverStat": "メモリー使用状況(平均)", "xpack.apm.serviceMap.avgReqPerMinutePopoverMetric": "1分あたりのリクエスト(平均)", "xpack.apm.serviceMap.avgTransDurationPopoverStat": "トランザクションの長さ(平均)", - "xpack.apm.serviceMap.betaBadge": "ベータ", - "xpack.apm.serviceMap.betaTooltipMessage": "現在、この機能はベータです。不具合を見つけた場合やご意見がある場合、サポートに問い合わせるか、またはディスカッションフォーラムにご報告ください。", "xpack.apm.serviceMap.center": "中央", "xpack.apm.serviceMap.download": "ダウンロード", "xpack.apm.serviceMap.emptyBanner.docsLink": "詳細はドキュメントをご覧ください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 28d9cfa4aaf0d4..d6baa87ca9e2f0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4809,8 +4809,6 @@ "xpack.apm.serviceMap.avgMemoryUsagePopoverStat": "内存使用率(平均值)", "xpack.apm.serviceMap.avgReqPerMinutePopoverMetric": "每分钟请求数(平均)", "xpack.apm.serviceMap.avgTransDurationPopoverStat": "事务持续时间(平均值)", - "xpack.apm.serviceMap.betaBadge": "公测版", - "xpack.apm.serviceMap.betaTooltipMessage": "此功能当前为公测版。如果遇到任何错误或有任何反馈,请报告问题或访问我们的论坛。", "xpack.apm.serviceMap.center": "中", "xpack.apm.serviceMap.download": "下载", "xpack.apm.serviceMap.emptyBanner.docsLink": "在文档中了解详情", From 99f652479a78a01e4cd29e2333415567f21fdd49 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Sep 2020 08:33:06 -0700 Subject: [PATCH 05/15] [Enterprise Search] Fix various plugin states when app has error connecting to Enterprise Search (#78091) * Display error connecting prompt on Overview page instead of blank page * Fix App Search and Workplace Search to not crash during error connecting - due to obj type errors --- .../applications/app_search/app_logic.test.ts | 9 +++++++++ .../applications/app_search/app_logic.ts | 4 ++-- .../error_connecting.test.tsx | 19 +++++++++++++++++++ .../error_connecting/error_connecting.tsx | 18 ++++++++++++++++++ .../components/error_connecting/index.ts | 7 +++++++ .../enterprise_search/index.test.tsx | 17 ++++++++++++++++- .../applications/enterprise_search/index.tsx | 8 +++++++- .../workplace_search/app_logic.test.ts | 10 ++++++++++ .../workplace_search/app_logic.ts | 11 +++++++---- 9 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/index.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts index 0f7bfe09edf7e4..9410b9ef7cb03a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.test.ts @@ -56,6 +56,15 @@ describe('AppLogic', () => { }), }); }); + + it('gracefully handles missing initial data', () => { + AppLogic.actions.initializeAppData({}); + + expect(AppLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasInitialized: true, + }); + }); }); describe('setOnboardingComplete()', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts index 8e5a8d75f407fa..932e84af45c2bf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/app_logic.ts @@ -39,7 +39,7 @@ export const AppLogic = kea>({ account: [ {}, { - initializeAppData: (_, { appSearch: account }) => account, + initializeAppData: (_, { appSearch: account }) => account || {}, setOnboardingComplete: (account) => ({ ...account, onboardingComplete: true, @@ -49,7 +49,7 @@ export const AppLogic = kea>({ configuredLimits: [ {}, { - initializeAppData: (_, { configuredLimits }) => configuredLimits.appSearch, + initializeAppData: (_, { configuredLimits }) => configuredLimits?.appSearch || {}, }, ], ilmEnabled: [ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx new file mode 100644 index 00000000000000..8d48875a8e1f5f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { ErrorConnecting } from './'; + +describe('ErrorConnecting', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx new file mode 100644 index 00000000000000..567c77792583d2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/error_connecting.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageContent } from '@elastic/eui'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; + +export const ErrorConnecting: React.FC = () => ( + + + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/index.ts new file mode 100644 index 00000000000000..c8b71e1a6e7918 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/error_connecting/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 { ErrorConnecting } from './error_connecting'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx index cd2a22a45bbb4c..b2918dac086f6c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.test.tsx @@ -6,13 +6,20 @@ import React from 'react'; import { shallow } from 'enzyme'; - import { EuiPage } from '@elastic/eui'; +import '../__mocks__/kea.mock'; +import { useValues } from 'kea'; + import { EnterpriseSearch } from './'; +import { ErrorConnecting } from './components/error_connecting'; import { ProductCard } from './components/product_card'; describe('EnterpriseSearch', () => { + beforeEach(() => { + (useValues as jest.Mock).mockReturnValue({ errorConnecting: false }); + }); + it('renders the overview page and product cards', () => { const wrapper = shallow( @@ -22,6 +29,14 @@ describe('EnterpriseSearch', () => { expect(wrapper.find(ProductCard)).toHaveLength(2); }); + it('renders the error connecting prompt', () => { + (useValues as jest.Mock).mockReturnValueOnce({ errorConnecting: true }); + const wrapper = shallow(); + + expect(wrapper.find(ErrorConnecting)).toHaveLength(1); + expect(wrapper.find(EuiPage)).toHaveLength(0); + }); + describe('access checks', () => { it('does not render the App Search card if the user does not have access to AS', () => { const wrapper = shallow( diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx index 373f595a6a9ea5..3a3ba02e07058a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/index.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { useValues } from 'kea'; import { EuiPage, EuiPageBody, @@ -21,9 +22,11 @@ import { i18n } from '@kbn/i18n'; import { IInitialAppData } from '../../../common/types'; import { APP_SEARCH_PLUGIN, WORKPLACE_SEARCH_PLUGIN } from '../../../common/constants'; +import { HttpLogic } from '../shared/http'; import { SetEnterpriseSearchChrome as SetPageChrome } from '../shared/kibana_chrome'; import { SendEnterpriseSearchTelemetry as SendTelemetry } from '../shared/telemetry'; +import { ErrorConnecting } from './components/error_connecting'; import { ProductCard } from './components/product_card'; import AppSearchImage from './assets/app_search.png'; @@ -31,9 +34,12 @@ import WorkplaceSearchImage from './assets/workplace_search.png'; import './index.scss'; export const EnterpriseSearch: React.FC = ({ access = {} }) => { + const { errorConnecting } = useValues(HttpLogic); const { hasAppSearchAccess, hasWorkplaceSearchAccess } = access; - return ( + return errorConnecting ? ( + + ) : ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts index c52eceb2d2fddd..974e07069ddba4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.test.ts @@ -50,5 +50,15 @@ describe('AppLogic', () => { expect(AppLogic.values).toEqual(expectedLogicValues); }); + + it('gracefully handles missing initial data', () => { + AppLogic.actions.initializeAppData({}); + + expect(AppLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasInitialized: true, + isFederatedAuth: false, + }); + }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts index 94bd1d529b65ff..629d1969a8f593 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/app_logic.ts @@ -21,6 +21,9 @@ export interface IAppActions { initializeAppData(props: IInitialAppData): IInitialAppData; } +const emptyOrg = {} as IOrganization; +const emptyAccount = {} as IAccount; + export const AppLogic = kea>({ path: ['enterprise_search', 'workplace_search', 'app_logic'], actions: { @@ -43,15 +46,15 @@ export const AppLogic = kea>({ }, ], organization: [ - {} as IOrganization, + emptyOrg, { - initializeAppData: (_, { workplaceSearch }) => workplaceSearch!.organization, + initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.organization || emptyOrg, }, ], account: [ - {} as IAccount, + emptyAccount, { - initializeAppData: (_, { workplaceSearch }) => workplaceSearch!.account, + initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount, }, ], }, From 7544a33901fe8ec084e937a77a44784290fb83b5 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 22 Sep 2020 18:00:19 +0200 Subject: [PATCH 06/15] [CSM] Use stacked chart for page views (#78042) --- .../RumDashboard/Charts/PageViewsChart.tsx | 25 +- .../lib/rum_client/get_page_view_trends.ts | 63 ++-- .../tests/csm/__snapshots__/page_views.snap | 280 ++++++++++++++++++ .../trial/tests/csm/page_views.ts | 65 ++++ .../apm_api_integration/trial/tests/index.ts | 1 + 5 files changed, 403 insertions(+), 31 deletions(-) create mode 100644 x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap create mode 100644 x-pack/test/apm_api_integration/trial/tests/csm/page_views.ts diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx index c76be19edfe473..904144dec6de93 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageViewsChart.tsx @@ -33,7 +33,10 @@ import { ChartWrapper } from '../ChartWrapper'; import { I18LABELS } from '../translations'; interface Props { - data?: Array>; + data?: { + topItems: string[]; + items: Array>; + }; loading: boolean; } @@ -68,15 +71,9 @@ export function PageViewsChart({ data, loading }: Props) { }); }; - let breakdownAccessors: Set = new Set(); - if (data && data.length > 0) { - data.forEach((item) => { - breakdownAccessors = new Set([ - ...Array.from(breakdownAccessors), - ...Object.keys(item).filter((key) => key !== 'x'), - ]); - }); - } + const breakdownAccessors = data?.topItems?.length ? data?.topItems : ['y']; + + const [darkMode] = useUiSetting$('theme:darkMode'); const customSeriesNaming: SeriesNameFn = ({ yAccessor }) => { if (yAccessor === 'y') { @@ -86,8 +83,6 @@ export function PageViewsChart({ data, loading }: Props) { return yAccessor; }; - const [darkMode] = useUiSetting$('theme:darkMode'); - return ( {(!loading || data) && ( @@ -115,7 +110,8 @@ export function PageViewsChart({ data, loading }: Props) { id="page_views" title={I18LABELS.pageViews} position={Position.Left} - tickFormat={(d) => numeral(d).format('0a')} + tickFormat={(d) => numeral(d).format('0')} + labelFormat={(d) => numeral(d).format('0a')} /> diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index f25062c67f87ad..543aa911b0b1fd 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -51,6 +51,16 @@ export async function getPageViewTrends({ } : undefined, }, + ...(breakdownItem + ? { + topBreakdowns: { + terms: { + field: breakdownItem.fieldName, + size: 9, + }, + }, + } + : {}), }, }, }); @@ -59,25 +69,44 @@ export async function getPageViewTrends({ const response = await apmEventClient.search(params); + const { topBreakdowns } = response.aggregations ?? {}; + + // we are only displaying top 9 + const topItems: string[] = (topBreakdowns?.buckets ?? []).map( + ({ key }) => key as string + ); + const result = response.aggregations?.pageViews.buckets ?? []; - return result.map((bucket) => { - const { key: xVal, doc_count: bCount } = bucket; - const res: Record = { - x: xVal, - y: bCount, - }; - if ('breakdown' in bucket) { - const categoryBuckets = bucket.breakdown.buckets; - categoryBuckets.forEach(({ key, doc_count: docCount }) => { - if (key === 'Other') { - res[key + `(${breakdownItem?.name})`] = docCount; - } else { - res[key] = docCount; + return { + topItems, + items: result.map((bucket) => { + const { key: xVal, doc_count: bCount } = bucket; + const res: Record = { + x: xVal, + y: bCount, + }; + if ('breakdown' in bucket) { + let top9Count = 0; + const categoryBuckets = bucket.breakdown.buckets; + categoryBuckets.forEach(({ key, doc_count: docCount }) => { + if (topItems.includes(key as string)) { + if (res[key]) { + // if term is already in object, just add it to it + res[key] += docCount; + } else { + res[key] = docCount; + } + top9Count += docCount; + } + }); + // Top 9 plus others, get a diff from parent bucket total + if (bCount > top9Count) { + res.Other = bCount - top9Count; } - }); - } + } - return res; - }); + return res; + }), + }; } diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap b/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap new file mode 100644 index 00000000000000..38b009fc73d346 --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/csm/__snapshots__/page_views.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSM page views when there is data returns page views 1`] = ` +Object { + "items": Array [ + Object { + "x": 1600149947000, + "y": 1, + }, + Object { + "x": 1600149957000, + "y": 0, + }, + Object { + "x": 1600149967000, + "y": 0, + }, + Object { + "x": 1600149977000, + "y": 0, + }, + Object { + "x": 1600149987000, + "y": 0, + }, + Object { + "x": 1600149997000, + "y": 0, + }, + Object { + "x": 1600150007000, + "y": 0, + }, + Object { + "x": 1600150017000, + "y": 0, + }, + Object { + "x": 1600150027000, + "y": 1, + }, + Object { + "x": 1600150037000, + "y": 0, + }, + Object { + "x": 1600150047000, + "y": 0, + }, + Object { + "x": 1600150057000, + "y": 0, + }, + Object { + "x": 1600150067000, + "y": 0, + }, + Object { + "x": 1600150077000, + "y": 1, + }, + Object { + "x": 1600150087000, + "y": 0, + }, + Object { + "x": 1600150097000, + "y": 0, + }, + Object { + "x": 1600150107000, + "y": 0, + }, + Object { + "x": 1600150117000, + "y": 0, + }, + Object { + "x": 1600150127000, + "y": 0, + }, + Object { + "x": 1600150137000, + "y": 0, + }, + Object { + "x": 1600150147000, + "y": 0, + }, + Object { + "x": 1600150157000, + "y": 0, + }, + Object { + "x": 1600150167000, + "y": 0, + }, + Object { + "x": 1600150177000, + "y": 1, + }, + Object { + "x": 1600150187000, + "y": 0, + }, + Object { + "x": 1600150197000, + "y": 0, + }, + Object { + "x": 1600150207000, + "y": 1, + }, + Object { + "x": 1600150217000, + "y": 0, + }, + Object { + "x": 1600150227000, + "y": 0, + }, + Object { + "x": 1600150237000, + "y": 1, + }, + ], + "topItems": Array [], +} +`; + +exports[`CSM page views when there is data returns page views with breakdown 1`] = ` +Object { + "items": Array [ + Object { + "Chrome": 1, + "x": 1600149947000, + "y": 1, + }, + Object { + "x": 1600149957000, + "y": 0, + }, + Object { + "x": 1600149967000, + "y": 0, + }, + Object { + "x": 1600149977000, + "y": 0, + }, + Object { + "x": 1600149987000, + "y": 0, + }, + Object { + "x": 1600149997000, + "y": 0, + }, + Object { + "x": 1600150007000, + "y": 0, + }, + Object { + "x": 1600150017000, + "y": 0, + }, + Object { + "Chrome": 1, + "x": 1600150027000, + "y": 1, + }, + Object { + "x": 1600150037000, + "y": 0, + }, + Object { + "x": 1600150047000, + "y": 0, + }, + Object { + "x": 1600150057000, + "y": 0, + }, + Object { + "x": 1600150067000, + "y": 0, + }, + Object { + "Chrome": 1, + "x": 1600150077000, + "y": 1, + }, + Object { + "x": 1600150087000, + "y": 0, + }, + Object { + "x": 1600150097000, + "y": 0, + }, + Object { + "x": 1600150107000, + "y": 0, + }, + Object { + "x": 1600150117000, + "y": 0, + }, + Object { + "x": 1600150127000, + "y": 0, + }, + Object { + "x": 1600150137000, + "y": 0, + }, + Object { + "x": 1600150147000, + "y": 0, + }, + Object { + "x": 1600150157000, + "y": 0, + }, + Object { + "x": 1600150167000, + "y": 0, + }, + Object { + "Chrome": 1, + "x": 1600150177000, + "y": 1, + }, + Object { + "x": 1600150187000, + "y": 0, + }, + Object { + "x": 1600150197000, + "y": 0, + }, + Object { + "Chrome Mobile": 1, + "x": 1600150207000, + "y": 1, + }, + Object { + "x": 1600150217000, + "y": 0, + }, + Object { + "x": 1600150227000, + "y": 0, + }, + Object { + "Chrome Mobile": 1, + "x": 1600150237000, + "y": 1, + }, + ], + "topItems": Array [ + "Chrome", + "Chrome Mobile", + ], +} +`; + +exports[`CSM page views when there is no data returns empty list 1`] = ` +Object { + "items": Array [], + "topItems": Array [], +} +`; + +exports[`CSM page views when there is no data returns empty list with breakdowns 1`] = ` +Object { + "items": Array [], + "topItems": Array [], +} +`; diff --git a/x-pack/test/apm_api_integration/trial/tests/csm/page_views.ts b/x-pack/test/apm_api_integration/trial/tests/csm/page_views.ts new file mode 100644 index 00000000000000..ca5670d41d8ee0 --- /dev/null +++ b/x-pack/test/apm_api_integration/trial/tests/csm/page_views.ts @@ -0,0 +1,65 @@ +/* + * 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'; +import { expectSnapshot } from '../../../common/match_snapshot'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function rumServicesApiTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('CSM page views', () => { + describe('when there is no data', () => { + it('returns empty list', async () => { + const response = await supertest.get( + '/api/apm/rum-client/page-view-trends?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D' + ); + + expect(response.status).to.be(200); + expectSnapshot(response.body).toMatch(); + }); + it('returns empty list with breakdowns', async () => { + const response = await supertest.get( + '/api/apm/rum-client/page-view-trends?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-14T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22elastic-co-rum-test%22%5D%7D&breakdowns=%7B%22name%22%3A%22Browser%22%2C%22fieldName%22%3A%22user_agent.name%22%2C%22type%22%3A%22category%22%7D' + ); + + expect(response.status).to.be(200); + expectSnapshot(response.body).toMatch(); + }); + }); + + describe('when there is data', () => { + before(async () => { + await esArchiver.load('8.0.0'); + await esArchiver.load('rum_8.0.0'); + }); + after(async () => { + await esArchiver.unload('8.0.0'); + await esArchiver.unload('rum_8.0.0'); + }); + + it('returns page views', async () => { + const response = await supertest.get( + '/api/apm/rum-client/page-view-trends?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D' + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatch(); + }); + it('returns page views with breakdown', async () => { + const response = await supertest.get( + '/api/apm/rum-client/page-view-trends?start=2020-09-07T20%3A35%3A54.654Z&end=2020-09-16T20%3A35%3A54.654Z&uiFilters=%7B%22serviceName%22%3A%5B%22kibana-frontend-8_0_0%22%5D%7D&breakdowns=%7B%22name%22%3A%22Browser%22%2C%22fieldName%22%3A%22user_agent.name%22%2C%22type%22%3A%22category%22%7D' + ); + + expect(response.status).to.be(200); + + expectSnapshot(response.body).toMatch(); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/trial/tests/index.ts b/x-pack/test/apm_api_integration/trial/tests/index.ts index ae62253c62d816..a026f91a02cd77 100644 --- a/x-pack/test/apm_api_integration/trial/tests/index.ts +++ b/x-pack/test/apm_api_integration/trial/tests/index.ts @@ -35,6 +35,7 @@ export default function observabilityApiIntegrationTests({ loadTestFile }: FtrPr loadTestFile(require.resolve('./csm/csm_services.ts')); loadTestFile(require.resolve('./csm/web_core_vitals.ts')); loadTestFile(require.resolve('./csm/long_task_metrics.ts')); + loadTestFile(require.resolve('./csm/page_views.ts')); }); }); } From d666038c8f6767f6790cb78f29e0027ff73d83ee Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Tue, 22 Sep 2020 12:40:38 -0400 Subject: [PATCH 07/15] Change saved objects client `find` to allow partial authorization (#77699) --- ...gin-core-public.savedobjectsfindoptions.md | 1 + ...dobjectsfindoptions.typetonamespacesmap.md | 13 ++ ...gin-core-server.savedobjectsfindoptions.md | 1 + ...dobjectsfindoptions.typetonamespacesmap.md | 13 ++ ...core-server.savedobjectsrepository.find.md | 4 +- ...ugin-core-server.savedobjectsrepository.md | 2 +- ...vedobjectsutils.createemptyfindresponse.md | 13 ++ ...na-plugin-core-server.savedobjectsutils.md | 1 + src/core/public/public.api.md | 1 + .../saved_objects/saved_objects_client.ts | 2 +- .../service/lib/repository.test.js | 93 +++++++-- .../saved_objects/service/lib/repository.ts | 65 ++++--- .../lib/search_dsl/query_params.test.ts | 99 ++++++---- .../service/lib/search_dsl/query_params.ts | 25 ++- .../service/lib/search_dsl/search_dsl.test.ts | 4 +- .../service/lib/search_dsl/search_dsl.ts | 3 + .../saved_objects/service/lib/utils.test.ts | 25 ++- .../server/saved_objects/service/lib/utils.ts | 18 ++ src/core/server/saved_objects/types.ts | 8 + src/core/server/server.api.md | 4 +- ...ecure_saved_objects_client_wrapper.test.ts | 75 ++++++- .../secure_saved_objects_client_wrapper.ts | 184 +++++++++++++----- .../server/lib/spaces_client/spaces_client.ts | 2 +- .../spaces_saved_objects_client.test.ts | 30 +++ .../spaces_saved_objects_client.ts | 37 ++-- .../common/lib/saved_object_test_cases.ts | 54 ++++- .../common/lib/saved_object_test_utils.ts | 45 ++--- .../common/lib/types.ts | 1 + .../common/suites/bulk_create.ts | 16 +- .../common/suites/bulk_get.ts | 5 +- .../common/suites/bulk_update.ts | 5 +- .../common/suites/create.ts | 13 +- .../common/suites/delete.ts | 5 +- .../common/suites/export.ts | 61 +++--- .../common/suites/find.ts | 166 ++++++---------- .../common/suites/get.ts | 2 +- .../common/suites/import.ts | 2 +- .../common/suites/resolve_import_errors.ts | 2 +- .../common/suites/update.ts | 5 +- .../security_and_spaces/apis/bulk_create.ts | 36 +++- .../security_and_spaces/apis/create.ts | 32 ++- .../security_and_spaces/apis/export.ts | 32 ++- .../security_and_spaces/apis/find.ts | 119 ++++++----- .../security_only/apis/bulk_create.ts | 28 ++- .../security_only/apis/create.ts | 27 ++- .../security_only/apis/export.ts | 32 ++- .../security_only/apis/find.ts | 44 ++--- .../spaces_only/apis/bulk_create.ts | 68 ++++--- .../spaces_only/apis/create.ts | 50 +++-- .../spaces_only/apis/find.ts | 19 +- 50 files changed, 1067 insertions(+), 525 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 903462ac3039d5..470a41f30afbfe 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions | [sortField](./kibana-plugin-core-public.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-public.savedobjectsfindoptions.sortorder.md) | string | | | [type](./kibana-plugin-core-public.savedobjectsfindoptions.type.md) | string | string[] | | +| [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md new file mode 100644 index 00000000000000..4af8c9ddeaff4f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-public.savedobjectsfindoptions.typetonamespacesmap.md) + +## SavedObjectsFindOptions.typeToNamespacesMap property + +This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + +Signature: + +```typescript +typeToNamespacesMap?: Map; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 804c83f7c1b48f..ce5c20e60ca118 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -29,4 +29,5 @@ export interface SavedObjectsFindOptions | [sortField](./kibana-plugin-core-server.savedobjectsfindoptions.sortfield.md) | string | | | [sortOrder](./kibana-plugin-core-server.savedobjectsfindoptions.sortorder.md) | string | | | [type](./kibana-plugin-core-server.savedobjectsfindoptions.type.md) | string | string[] | | +| [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) | Map<string, string[] | undefined> | This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the type and namespaces fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md new file mode 100644 index 00000000000000..8bec759f055804 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [typeToNamespacesMap](./kibana-plugin-core-server.savedobjectsfindoptions.typetonamespacesmap.md) + +## SavedObjectsFindOptions.typeToNamespacesMap property + +This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved object client wrapper. If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. Any types that are not included in this map will be excluded entirely. If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + +Signature: + +```typescript +typeToNamespacesMap?: Map; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index 1b562263145daf..d3e93e7af2aa07 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,14 +7,14 @@ Signature: ```typescript -find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find(options: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | +| options | SavedObjectsFindOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index 14d3741425987f..1d11d5262a9c42 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -24,7 +24,7 @@ export declare class SavedObjectsRepository | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | -| [find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find(options)](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md new file mode 100644 index 00000000000000..40e865cb02ce8e --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) + +## SavedObjectsUtils.createEmptyFindResponse property + +Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. + +Signature: + +```typescript +static createEmptyFindResponse: ({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md index e365dfbcb51426..83831f65bd41a7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -15,6 +15,7 @@ export declare class SavedObjectsUtils | Property | Modifiers | Type | Description | | --- | --- | --- | --- | +| [createEmptyFindResponse](./kibana-plugin-core-server.savedobjectsutils.createemptyfindresponse.md) | static | <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T> | Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. | | [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static | (namespace?: string | undefined) => string | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined namespace ID (which has a namespace string of 'default'). | | [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static | (namespace: string) => string | undefined | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default' namespace string (which has a namespace ID of undefined). | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 1c17be50454c55..7179c6cf8b1332 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1079,6 +1079,7 @@ export interface SavedObjectsFindOptions { sortOrder?: string; // (undocumented) type: string | string[]; + typeToNamespacesMap?: Map; } // @public diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 5a8949ca2f55ff..6a10eb44d9ca49 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -34,7 +34,7 @@ import { HttpFetchOptions, HttpSetup } from '../http'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'namespace' | 'sortOrder' | 'rootSearchFields' + 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' >; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 352ce4c1c16eb2..0e72ad2fec06cd 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -2477,6 +2477,33 @@ describe('SavedObjectsRepository', () => { expect(client.search).not.toHaveBeenCalled(); }); + it(`throws when namespaces is an empty array`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', namespaces: [] }) + ).rejects.toThrowError('options.namespaces cannot be an empty array'); + expect(client.search).not.toHaveBeenCalled(); + }); + + it(`throws when type is not falsy and typeToNamespacesMap is defined`, async () => { + await expect( + savedObjectsRepository.find({ type: 'foo', typeToNamespacesMap: new Map() }) + ).rejects.toThrowError( + 'options.type must be an empty string when options.typeToNamespacesMap is used' + ); + expect(client.search).not.toHaveBeenCalled(); + }); + + it(`throws when type is not an empty array and typeToNamespacesMap is defined`, async () => { + const test = async (args) => { + await expect(savedObjectsRepository.find(args)).rejects.toThrowError( + 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' + ); + expect(client.search).not.toHaveBeenCalled(); + }; + await test({ type: '', typeToNamespacesMap: new Map() }); + await test({ type: '', namespaces: ['some-ns'], typeToNamespacesMap: new Map() }); + }); + it(`throws when searchFields is defined but not an array`, async () => { await expect( savedObjectsRepository.find({ type, searchFields: 'string' }) @@ -2493,7 +2520,7 @@ describe('SavedObjectsRepository', () => { it(`throws when KQL filter syntax is invalid`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], @@ -2577,38 +2604,70 @@ describe('SavedObjectsRepository', () => { const test = async (types) => { const result = await savedObjectsRepository.find({ type: types }); expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + expect(client.search).not.toHaveBeenCalled(); }; await test('unknownType'); await test(HIDDEN_TYPE); await test(['unknownType', HIDDEN_TYPE]); }); + + it(`should return empty results when attempting to find only invalid or hidden types using typeToNamespacesMap`, async () => { + const test = async (types) => { + const result = await savedObjectsRepository.find({ + typeToNamespacesMap: new Map(types.map((x) => [x, undefined])), + type: '', + namespaces: [], + }); + expect(result).toEqual(expect.objectContaining({ saved_objects: [] })); + expect(client.search).not.toHaveBeenCalled(); + }; + + await test(['unknownType']); + await test([HIDDEN_TYPE]); + await test(['unknownType', HIDDEN_TYPE]); + }); }); describe('search dsl', () => { - it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { + const commonOptions = { + type: [type], // cannot be used when `typeToNamespacesMap` is present + namespaces: [namespace], // cannot be used when `typeToNamespacesMap` is present + search: 'foo*', + searchFields: ['foo'], + sortField: 'name', + sortOrder: 'desc', + defaultSearchOperator: 'AND', + hasReference: { + type: 'foo', + id: '1', + }, + kueryNode: undefined, + }; + + it(`passes mappings, registry, and search options to getSearchDsl`, async () => { + await findSuccess(commonOptions, namespace); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, commonOptions); + }); + + it(`accepts typeToNamespacesMap`, async () => { const relevantOpts = { - namespaces: [namespace], - search: 'foo*', - searchFields: ['foo'], - type: [type], - sortField: 'name', - sortOrder: 'desc', - defaultSearchOperator: 'AND', - hasReference: { - type: 'foo', - id: '1', - }, - kueryNode: undefined, + ...commonOptions, + type: '', + namespaces: [], + typeToNamespacesMap: new Map([[type, [namespace]]]), // can only be used when `type` is falsy and `namespaces` is an empty array }; await findSuccess(relevantOpts, namespace); - expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, relevantOpts); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + ...relevantOpts, + type: [type], + }); }); it(`accepts KQL expression filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], @@ -2649,7 +2708,7 @@ describe('SavedObjectsRepository', () => { it(`accepts KQL KueryNode filter and passes KueryNode to getSearchDsl`, async () => { const findOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: ['dashboard'], diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 125f97e7feb116..a83c86e5856289 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -67,7 +67,7 @@ import { } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; -import { SavedObjectsUtils } from './utils'; +import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -693,37 +693,51 @@ export class SavedObjectsRepository { * @property {string} [options.preference] * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ - async find({ - search, - defaultSearchOperator = 'OR', - searchFields, - rootSearchFields, - hasReference, - page = 1, - perPage = 20, - sortField, - sortOrder, - fields, - namespaces, - type, - filter, - preference, - }: SavedObjectsFindOptions): Promise> { - if (!type) { + async find(options: SavedObjectsFindOptions): Promise> { + const { + search, + defaultSearchOperator = 'OR', + searchFields, + rootSearchFields, + hasReference, + page = FIND_DEFAULT_PAGE, + perPage = FIND_DEFAULT_PER_PAGE, + sortField, + sortOrder, + fields, + namespaces, + type, + typeToNamespacesMap, + filter, + preference, + } = options; + + if (!type && !typeToNamespacesMap) { throw SavedObjectsErrorHelpers.createBadRequestError( 'options.type must be a string or an array of strings' ); + } else if (namespaces?.length === 0 && !typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces cannot be an empty array' + ); + } else if (type && typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.type must be an empty string when options.typeToNamespacesMap is used' + ); + } else if ((!namespaces || namespaces?.length) && typeToNamespacesMap) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'options.namespaces must be an empty array when options.typeToNamespacesMap is used' + ); } - const types = Array.isArray(type) ? type : [type]; + const types = type + ? Array.isArray(type) + ? type + : [type] + : Array.from(typeToNamespacesMap!.keys()); const allowedTypes = types.filter((t) => this._allowedTypes.includes(t)); if (allowedTypes.length === 0) { - return { - page, - per_page: perPage, - total: 0, - saved_objects: [], - }; + return SavedObjectsUtils.createEmptyFindResponse(options); } if (searchFields && !Array.isArray(searchFields)) { @@ -766,6 +780,7 @@ export class SavedObjectsRepository { sortField, sortOrder, namespaces, + typeToNamespacesMap, hasReference, kueryNode, }), diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 4adc92df318058..e13c67a7204000 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -50,6 +50,40 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( .filter((x) => x.length) // exclude empty set .map((x) => (x.length === 1 ? x[0] : x)); // if a subset is a single string, destructure it +const createTypeClause = (type: string, namespaces?: string[]) => { + if (registry.isMultiNamespace(type)) { + return { + bool: { + must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), + must_not: [{ exists: { field: 'namespace' } }], + }, + }; + } else if (registry.isSingleNamespace(type)) { + const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; + const should: any = []; + if (nonDefaultNamespaces.length > 0) { + should.push({ terms: { namespace: nonDefaultNamespaces } }); + } + if (namespaces?.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + return { + bool: { + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, + must_not: [{ exists: { field: 'namespaces' } }], + }, + }; + } + // isNamespaceAgnostic + return { + bool: expect.objectContaining({ + must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], + }), + }; +}; + /** * Note: these tests cases are defined in the order they appear in the source code, for readability's sake */ @@ -198,40 +232,6 @@ describe('#getQueryParams', () => { }); describe('`namespaces` parameter', () => { - const createTypeClause = (type: string, namespaces?: string[]) => { - if (registry.isMultiNamespace(type)) { - return { - bool: { - must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), - must_not: [{ exists: { field: 'namespace' } }], - }, - }; - } else if (registry.isSingleNamespace(type)) { - const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; - const should: any = []; - if (nonDefaultNamespaces.length > 0) { - should.push({ terms: { namespace: nonDefaultNamespaces } }); - } - if (namespaces?.includes('default')) { - should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); - } - return { - bool: { - must: [{ term: { type } }], - should: expect.arrayContaining(should), - minimum_should_match: 1, - must_not: [{ exists: { field: 'namespaces' } }], - }, - }; - } - // isNamespaceAgnostic - return { - bool: expect.objectContaining({ - must_not: [{ exists: { field: 'namespace' } }, { exists: { field: 'namespaces' } }], - }), - }; - }; - const expectResult = (result: Result, ...typeClauses: any) => { expect(result.query.bool.filter).toEqual( expect.arrayContaining([ @@ -281,6 +281,37 @@ describe('#getQueryParams', () => { test(['default']); }); }); + + describe('`typeToNamespacesMap` parameter', () => { + const expectResult = (result: Result, ...typeClauses: any) => { + expect(result.query.bool.filter).toEqual( + expect.arrayContaining([ + { bool: expect.objectContaining({ should: typeClauses, minimum_should_match: 1 }) }, + ]) + ); + }; + + it('supersedes `type` and `namespaces` parameters', () => { + const result = getQueryParams({ + mappings, + registry, + type: ['pending', 'saved', 'shared', 'global'], + namespaces: ['foo', 'bar', 'default'], + typeToNamespacesMap: new Map([ + ['pending', ['foo']], // 'pending' is only authorized in the 'foo' namespace + // 'saved' is not authorized in any namespaces + ['shared', ['bar', 'default']], // 'shared' is only authorized in the 'bar' and 'default' namespaces + ['global', ['foo', 'bar', 'default']], // 'global' is authorized in all namespaces (which are ignored anyway) + ]), + }); + expectResult( + result, + createTypeClause('pending', ['foo']), + createTypeClause('shared', ['bar', 'default']), + createTypeClause('global') + ); + }); + }); }); describe('search clause (query.bool.must.simple_query_string)', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 642d51c70766e4..eaddc05fa921c1 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -129,6 +129,7 @@ interface QueryParams { registry: ISavedObjectTypeRegistry; namespaces?: string[]; type?: string | string[]; + typeToNamespacesMap?: Map; search?: string; searchFields?: string[]; rootSearchFields?: string[]; @@ -145,6 +146,7 @@ export function getQueryParams({ registry, namespaces, type, + typeToNamespacesMap, search, searchFields, rootSearchFields, @@ -152,7 +154,10 @@ export function getQueryParams({ hasReference, kueryNode, }: QueryParams) { - const types = getTypes(mappings, type); + const types = getTypes( + mappings, + typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type + ); // A de-duplicated set of namespaces makes for a more effecient query. // @@ -163,9 +168,12 @@ export function getQueryParams({ // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 - const normalizedNamespaces = namespaces - ? Array.from(new Set(namespaces.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x)))) - : undefined; + const normalizeNamespaces = (namespacesToNormalize?: string[]) => + namespacesToNormalize + ? Array.from( + new Set(namespacesToNormalize.map((x) => (x === '*' ? DEFAULT_NAMESPACE_STRING : x))) + ) + : undefined; const bool: any = { filter: [ @@ -197,9 +205,12 @@ export function getQueryParams({ }, ] : undefined, - should: types.map((shouldType) => - getClauseForType(registry, normalizedNamespaces, shouldType) - ), + should: types.map((shouldType) => { + const normalizedNamespaces = normalizeNamespaces( + typeToNamespacesMap ? typeToNamespacesMap.get(shouldType) : namespaces + ); + return getClauseForType(registry, normalizedNamespaces, shouldType); + }), minimum_should_match: 1, }, }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 62e629ad33cc88..7276e505bce7d6 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,10 +57,11 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespaces, type, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, typeToNamespacesMap, search, searchFields, rootSearchFields, hasReference) to getQueryParams', () => { const opts = { namespaces: ['foo-namespace'], type: 'foo', + typeToNamespacesMap: new Map(), search: 'bar', searchFields: ['baz'], rootSearchFields: ['qux'], @@ -78,6 +79,7 @@ describe('getSearchDsl', () => { registry, namespaces: opts.namespaces, type: opts.type, + typeToNamespacesMap: opts.typeToNamespacesMap, search: opts.search, searchFields: opts.searchFields, rootSearchFields: opts.rootSearchFields, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index aa79a10b2a9bef..858770579fb9e6 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -35,6 +35,7 @@ interface GetSearchDslOptions { sortField?: string; sortOrder?: string; namespaces?: string[]; + typeToNamespacesMap?: Map; hasReference?: { type: string; id: string; @@ -56,6 +57,7 @@ export function getSearchDsl( sortField, sortOrder, namespaces, + typeToNamespacesMap, hasReference, kueryNode, } = options; @@ -74,6 +76,7 @@ export function getSearchDsl( registry, namespaces, type, + typeToNamespacesMap, search, searchFields, rootSearchFields, diff --git a/src/core/server/saved_objects/service/lib/utils.test.ts b/src/core/server/saved_objects/service/lib/utils.test.ts index ea4fa68242beaf..ac06ca92757831 100644 --- a/src/core/server/saved_objects/service/lib/utils.test.ts +++ b/src/core/server/saved_objects/service/lib/utils.test.ts @@ -17,10 +17,11 @@ * under the License. */ +import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsUtils } from './utils'; describe('SavedObjectsUtils', () => { - const { namespaceIdToString, namespaceStringToId } = SavedObjectsUtils; + const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils; describe('#namespaceIdToString', () => { it('converts `undefined` to default namespace string', () => { @@ -54,4 +55,26 @@ describe('SavedObjectsUtils', () => { test(''); }); }); + + describe('#createEmptyFindResponse', () => { + it('returns expected result', () => { + const options = {} as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options)).toEqual({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + }); + + it('handles `page` field', () => { + const options = { page: 42 } as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options).page).toEqual(42); + }); + + it('handles `perPage` field', () => { + const options = { perPage: 42 } as SavedObjectsFindOptions; + expect(createEmptyFindResponse(options).per_page).toEqual(42); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 6101ad57cc4010..3efe8614da1d79 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -17,7 +17,12 @@ * under the License. */ +import { SavedObjectsFindOptions } from '../../types'; +import { SavedObjectsFindResponse } from '..'; + export const DEFAULT_NAMESPACE_STRING = 'default'; +export const FIND_DEFAULT_PAGE = 1; +export const FIND_DEFAULT_PER_PAGE = 20; /** * @public @@ -50,4 +55,17 @@ export class SavedObjectsUtils { return namespace !== DEFAULT_NAMESPACE_STRING ? namespace : undefined; }; + + /** + * Creates an empty response for a find operation. This is only intended to be used by saved objects client wrappers. + */ + public static createEmptyFindResponse = ({ + page = FIND_DEFAULT_PAGE, + perPage = FIND_DEFAULT_PER_PAGE, + }: SavedObjectsFindOptions): SavedObjectsFindResponse => ({ + page, + per_page: perPage, + total: 0, + saved_objects: [], + }); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 1885f5ec50139b..01128e4f8cf517 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -89,6 +89,14 @@ export interface SavedObjectsFindOptions { defaultSearchOperator?: 'AND' | 'OR'; filter?: string | KueryNode; namespaces?: string[]; + /** + * This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved + * object client wrapper. + * If this is defined, it supersedes the `type` and `namespaces` fields when building the Elasticsearch query. + * Any types that are not included in this map will be excluded entirely. + * If a type is included but its value is undefined, the operation will search for that type in the Default namespace. + */ + typeToNamespacesMap?: Map; /** An optional ES preference value to be used for the query **/ preference?: string; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index d755ef3e1b6765..8a764d9bd2f661 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2177,6 +2177,7 @@ export interface SavedObjectsFindOptions { sortOrder?: string; // (undocumented) type: string | string[]; + typeToNamespacesMap?: Map; } // @public @@ -2388,7 +2389,7 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; // (undocumented) - find({ search, defaultSearchOperator, searchFields, rootSearchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find(options: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise; update(type: string, id: string, attributes: Partial, options?: SavedObjectsUpdateOptions): Promise>; @@ -2496,6 +2497,7 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; static namespaceIdToString: (namespace?: string | undefined) => string; static namespaceStringToId: (namespace: string) => string | undefined; } diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 7ada34ff5ccac3..86d1b68ba761ed 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -609,22 +609,83 @@ describe('#find', () => { await expectGeneralError(client.find, { type: type1 }); }); - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + test(`returns empty result when unauthorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesFailure + ); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); - await expectForbiddenError(client.find, { options }); - }); + const result = await client.find(options); - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); - await expectForbiddenError(client.find, { options }); + expect(clientOpts.baseClient.find).not.toHaveBeenCalled(); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + USERNAME, + 'find', + [type1], + options.namespaces, + [{ spaceId: 'some-ns', privilege: 'mock-saved_object:foo/find' }], + { options } + ); + expect(result).toEqual({ page: 1, per_page: 20, total: 0, saved_objects: [] }); }); - test(`returns result of baseClient.find when authorized`, async () => { + test(`returns result of baseClient.find when fully authorized`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); const result = await expectSuccess(client.find, { options }); + expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ + ...options, + typeToNamespacesMap: undefined, + }); + expect(result).toEqual(apiCallReturnValue); + }); + + test(`returns result of baseClient.find when partially authorized`, async () => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ + hasAllRequested: false, + username: USERNAME, + privileges: { + kibana: [ + { resource: 'some-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, + { resource: 'some-ns', privilege: 'mock-saved_object:bar/find', authorized: true }, + { resource: 'some-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, + { resource: 'some-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + { resource: 'another-ns', privilege: 'mock-saved_object:foo/find', authorized: true }, + { resource: 'another-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, + { resource: 'another-ns', privilege: 'mock-saved_object:baz/find', authorized: true }, + { resource: 'another-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:foo/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:bar/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:baz/find', authorized: false }, + { resource: 'forbidden-ns', privilege: 'mock-saved_object:qux/find', authorized: false }, + ], + }, + }); + + const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); + + const options = Object.freeze({ + type: ['foo', 'bar', 'baz', 'qux'], + namespaces: ['some-ns', 'another-ns', 'forbidden-ns'], + }); + const result = await client.find(options); + // 'expect(clientOpts.baseClient.find).toHaveBeenCalledWith' resulted in false negatives, resorting to manually comparing mock call args + expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ + ...options, + typeToNamespacesMap: new Map([ + ['foo', ['some-ns', 'another-ns']], + ['bar', ['some-ns']], + ['baz', ['another-ns']], + // qux is not authorized, so there is no entry for it + // forbidden-ns is completely forbidden, so there are no entries with this namespace + ]), + type: '', + namespaces: [], + }); expect(result).toEqual(apiCallReturnValue); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 16e52c69f274f0..f5de8f4b226f34 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -16,6 +16,7 @@ import { SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsUtils, } from '../../../../../src/core/server'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; @@ -39,8 +40,19 @@ interface SavedObjectsNamespaces { saved_objects: SavedObjectNamespaces[]; } -function uniq(arr: T[]): T[] { - return Array.from(new Set(arr)); +interface EnsureAuthorizedOptions { + args?: Record; + auditAction?: string; + requireFullAuthorization?: boolean; +} + +interface EnsureAuthorizedResult { + status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; + typeMap: Map; +} +interface EnsureAuthorizedTypeResult { + authorizedSpaces: string[]; + isGloballyAuthorized?: boolean; } export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { @@ -72,7 +84,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); + const args = { type, attributes, options }; + await this.ensureAuthorized(type, 'create', options.namespace, { args }); const savedObject = await this.baseClient.create(type, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -82,9 +95,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} ) { - const types = this.getUniqueObjectTypes(objects); const args = { objects, options }; - await this.ensureAuthorized(types, 'bulk_create', options.namespace, args, 'checkConflicts'); + const types = this.getUniqueObjectTypes(objects); + await this.ensureAuthorized(types, 'bulk_create', options.namespace, { + args, + auditAction: 'checkConflicts', + }); const response = await this.baseClient.checkConflicts(objects, options); return response; @@ -94,11 +110,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array>, options: SavedObjectsBaseOptions = {} ) { + const args = { objects, options }; await this.ensureAuthorized( this.getUniqueObjectTypes(objects), 'bulk_create', options.namespace, - { objects, options } + { args } ); const response = await this.baseClient.bulkCreate(objects, options); @@ -106,7 +123,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options }); + const args = { type, id, options }; + await this.ensureAuthorized(type, 'delete', options.namespace, { args }); return await this.baseClient.delete(type, id, options); } @@ -121,9 +139,29 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra `_find across namespaces is not permitted when the Spaces plugin is disabled.` ); } - await this.ensureAuthorized(options.type, 'find', options.namespaces, { options }); + const args = { options }; + const { status, typeMap } = await this.ensureAuthorized( + options.type, + 'find', + options.namespaces, + { args, requireFullAuthorization: false } + ); + + if (status === 'unauthorized') { + // return empty response + return SavedObjectsUtils.createEmptyFindResponse(options); + } - const response = await this.baseClient.find(options); + const typeToNamespacesMap = Array.from(typeMap).reduce>( + (acc, [type, { authorizedSpaces, isGloballyAuthorized }]) => + isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces), + new Map() + ); + const response = await this.baseClient.find({ + ...options, + typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation + ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined + }); return await this.redactSavedObjectsNamespaces(response); } @@ -131,9 +169,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { + const args = { objects, options }; await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, { - objects, - options, + args, }); const response = await this.baseClient.bulkGet(objects, options); @@ -141,7 +179,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { - await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); + const args = { type, id, options }; + await this.ensureAuthorized(type, 'get', options.namespace, { args }); const savedObject = await this.baseClient.get(type, id, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -154,7 +193,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsUpdateOptions = {} ) { const args = { type, id, attributes, options }; - await this.ensureAuthorized(type, 'update', options.namespace, args); + await this.ensureAuthorized(type, 'update', options.namespace, { args }); const savedObject = await this.baseClient.update(type, id, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -169,13 +208,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const args = { type, id, namespaces, options }; const { namespace } = options; // To share an object, the user must have the "create" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'create', namespaces, args, 'addToNamespacesCreate'); + await this.ensureAuthorized(type, 'create', namespaces, { + args, + auditAction: 'addToNamespacesCreate', + }); // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will // result in a 404 error. - await this.ensureAuthorized(type, 'update', namespace, args, 'addToNamespacesUpdate'); + await this.ensureAuthorized(type, 'update', namespace, { + args, + auditAction: 'addToNamespacesUpdate', + }); const result = await this.baseClient.addToNamespaces(type, id, namespaces, options); return await this.redactSavedObjectNamespaces(result); @@ -189,7 +234,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { type, id, namespaces, options }; // To un-share an object, the user must have the "delete" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'delete', namespaces, args, 'deleteFromNamespaces'); + await this.ensureAuthorized(type, 'delete', namespaces, { + args, + auditAction: 'deleteFromNamespaces', + }); const result = await this.baseClient.deleteFromNamespaces(type, id, namespaces, options); return await this.redactSavedObjectNamespaces(result); @@ -205,9 +253,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra .filter(({ namespace }) => namespace !== undefined) .map(({ namespace }) => namespace!); const namespaces = [options?.namespace, ...objectNamespaces]; + const args = { objects, options }; await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { - objects, - options, + args, }); const response = await this.baseClient.bulkUpdate(objects, options); @@ -228,11 +276,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async ensureAuthorized( typeOrTypes: string | string[], action: string, - namespaceOrNamespaces?: string | Array, - args?: Record, - auditAction: string = action, - requiresAll = true - ) { + namespaceOrNamespaces: undefined | string | Array, + options: EnsureAuthorizedOptions = {} + ): Promise { + const { args, auditAction = action, requireFullAuthorization = true } = options; const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const actionsToTypesMap = new Map( types.map((type) => [this.actions.savedObject.get(type, action), type]) @@ -245,27 +292,60 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined) ).sort() as string[]; - const isAuthorized = - (requiresAll && hasAllRequested) || - (!requiresAll && privileges.kibana.some(({ authorized }) => authorized)); - if (isAuthorized) { - this.auditLogger.savedObjectsAuthorizationSuccess( + const missingPrivileges = this.getMissingPrivileges(privileges); + const typeMap = privileges.kibana.reduce>( + (acc, { resource, privilege, authorized }) => { + if (!authorized) { + return acc; + } + const type = actionsToTypesMap.get(privilege)!; // always defined + const value = acc.get(type) ?? { authorizedSpaces: [] }; + if (resource === undefined) { + return acc.set(type, { ...value, isGloballyAuthorized: true }); + } + const authorizedSpaces = value.authorizedSpaces.concat(resource); + return acc.set(type, { ...value, authorizedSpaces }); + }, + new Map() + ); + + const logAuthorizationFailure = () => { + this.auditLogger.savedObjectsAuthorizationFailure( username, auditAction, types, spaceIds, + missingPrivileges, args ); - } else { - const missingPrivileges = this.getMissingPrivileges(privileges); - this.auditLogger.savedObjectsAuthorizationFailure( + }; + const logAuthorizationSuccess = (typeArray: string[], spaceIdArray: string[]) => { + this.auditLogger.savedObjectsAuthorizationSuccess( username, auditAction, - types, - spaceIds, - missingPrivileges, + typeArray, + spaceIdArray, args ); + }; + + if (hasAllRequested) { + logAuthorizationSuccess(types, spaceIds); + return { typeMap, status: 'fully_authorized' }; + } else if (!requireFullAuthorization) { + const isPartiallyAuthorized = privileges.kibana.some(({ authorized }) => authorized); + if (isPartiallyAuthorized) { + for (const [type, { isGloballyAuthorized, authorizedSpaces }] of typeMap.entries()) { + // generate an individual audit record for each authorized type + logAuthorizationSuccess([type], isGloballyAuthorized ? spaceIds : authorizedSpaces); + } + return { typeMap, status: 'partially_authorized' }; + } else { + logAuthorizationFailure(); + return { typeMap, status: 'unauthorized' }; + } + } else { + logAuthorizationFailure(); const targetTypes = uniq( missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() ).join(','); @@ -303,19 +383,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { - const comparator = (a: string, b: string) => { - const _a = a.toLowerCase(); - const _b = b.toLowerCase(); - if (_a === '?') { - return 1; - } else if (_a < _b) { - return -1; - } else if (_a > _b) { - return 1; - } - return 0; - }; - return spaceIds.map((spaceId) => (privilegeMap[spaceId] ? spaceId : '?')).sort(comparator); + return spaceIds.map((x) => (privilegeMap[x] ? x : '?')).sort(namespaceComparator); } private async redactSavedObjectNamespaces( @@ -362,3 +430,25 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }; } } + +/** + * Returns all unique elements of an array. + */ +function uniq(arr: T[]): T[] { + return Array.from(new Set(arr)); +} + +/** + * Utility function to sort potentially redacted namespaces. + * Sorts in a case-insensitive manner, and ensures that redacted namespaces ('?') always show up at the end of the array. + */ +function namespaceComparator(a: string, b: string) { + const A = a.toUpperCase(); + const B = b.toUpperCase(); + if (A === '?' && B !== '?') { + return 1; + } else if (A !== '?' && B === '?') { + return -1; + } + return A > B ? 1 : A < B ? -1 : 0; +} diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index acb00a87bf7d93..5ef0b5375d7969 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -104,7 +104,7 @@ export class SpacesClient { `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces for ${purpose} purpose.` ); this.auditLogger.spacesAuthorizationFailure(username, 'getAll'); - throw Boom.forbidden(); + throw Boom.forbidden(); // Note: there is a catch for this in `SpacesSavedObjectsClient.find`; if we get rid of this error, remove that too } this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized as string[]); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index c9c17d091cd55c..f7621f11a1c053 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -10,6 +10,8 @@ import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { SavedObjectTypeRegistry } from 'src/core/server'; import { SpacesClient } from '../lib/spaces_client'; +import { spacesClientMock } from '../lib/spaces_client/spaces_client.mock'; +import Boom from 'boom'; const typeRegistry = new SavedObjectTypeRegistry(); typeRegistry.registerType({ @@ -129,6 +131,34 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); describe('#find', () => { + const EMPTY_RESPONSE = { saved_objects: [], total: 0, per_page: 20, page: 1 }; + + test(`returns empty result if user is unauthorized in this space`, async () => { + const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const spacesClient = spacesClientMock.create(); + spacesClient.getAll.mockResolvedValue([]); + spacesService.scopedClient.mockResolvedValue(spacesClient); + + const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toEqual(EMPTY_RESPONSE); + expect(baseClient.find).not.toHaveBeenCalled(); + }); + + test(`returns empty result if user is unauthorized in any space`, async () => { + const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const spacesClient = spacesClientMock.create(); + spacesClient.getAll.mockRejectedValue(Boom.unauthorized()); + spacesService.scopedClient.mockResolvedValue(spacesClient); + + const options = Object.freeze({ type: 'foo', namespaces: ['some-ns'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toEqual(EMPTY_RESPONSE); + expect(baseClient.find).not.toHaveBeenCalled(); + }); + test(`passes options.type to baseClient if valid singular type specified`, async () => { const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 4e830d6149537c..a65e0431aef920 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, @@ -16,8 +17,9 @@ import { SavedObjectsUpdateOptions, SavedObjectsAddToNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsUtils, ISavedObjectTypeRegistry, -} from 'src/core/server'; +} from '../../../../../src/core/server'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../lib/utils/namespace'; import { SpacesClient } from '../lib/spaces_client'; @@ -164,19 +166,26 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { let namespaces = options.namespaces; if (namespaces) { const spacesClient = await this.getSpacesClient; - const availableSpaces = await spacesClient.getAll('findSavedObjects'); - if (namespaces.includes('*')) { - namespaces = availableSpaces.map((space) => space.id); - } else { - namespaces = namespaces.filter((namespace) => - availableSpaces.some((space) => space.id === namespace) - ); - } - // This forbidden error allows this scenario to be consistent - // with the way the SpacesClient behaves when no spaces are authorized - // there. - if (namespaces.length === 0) { - throw this.errors.decorateForbiddenError(new Error()); + + try { + const availableSpaces = await spacesClient.getAll('findSavedObjects'); + if (namespaces.includes('*')) { + namespaces = availableSpaces.map((space) => space.id); + } else { + namespaces = namespaces.filter((namespace) => + availableSpaces.some((space) => space.id === namespace) + ); + } + if (namespaces.length === 0) { + // return empty response, since the user is unauthorized in this space (or these spaces), but we don't return forbidden errors for `find` operations + return SavedObjectsUtils.createEmptyFindResponse(options); + } + } catch (err) { + if (Boom.isBoom(err) && err.output.payload.statusCode === 403) { + // return empty response, since the user is unauthorized in any space, but we don't return forbidden errors for `find` operations + return SavedObjectsUtils.createEmptyFindResponse(options); + } + throw err; } } else { namespaces = [this.spaceId]; diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index b32950538f8e53..190b12e038b276 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -4,30 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -export const SAVED_OBJECT_TEST_CASES = Object.freeze({ +import { SPACES } from './spaces'; +import { TestCase } from './types'; + +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + +type CommonTestCase = Omit & { originId?: string }; + +export const SAVED_OBJECT_TEST_CASES: Record = Object.freeze({ SINGLE_NAMESPACE_DEFAULT_SPACE: Object.freeze({ type: 'isolatedtype', id: 'defaultspace-isolatedtype-id', + expectedNamespaces: [DEFAULT_SPACE_ID], }), SINGLE_NAMESPACE_SPACE_1: Object.freeze({ type: 'isolatedtype', id: 'space1-isolatedtype-id', + expectedNamespaces: [SPACE_1_ID], }), SINGLE_NAMESPACE_SPACE_2: Object.freeze({ type: 'isolatedtype', id: 'space2-isolatedtype-id', + expectedNamespaces: [SPACE_2_ID], }), MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({ type: 'sharedtype', id: 'default_and_space_1', + expectedNamespaces: [DEFAULT_SPACE_ID, SPACE_1_ID], }), MULTI_NAMESPACE_ONLY_SPACE_1: Object.freeze({ type: 'sharedtype', id: 'only_space_1', + expectedNamespaces: [SPACE_1_ID], }), MULTI_NAMESPACE_ONLY_SPACE_2: Object.freeze({ type: 'sharedtype', id: 'only_space_2', + expectedNamespaces: [SPACE_2_ID], }), NAMESPACE_AGNOSTIC: Object.freeze({ type: 'globaltype', @@ -38,3 +56,37 @@ export const SAVED_OBJECT_TEST_CASES = Object.freeze({ id: 'any', }), }); + +/** + * These objects exist in the test data for all saved object test suites, but they are only used to test various conflict scenarios. + */ +export const CONFLICT_TEST_CASES: Record = Object.freeze({ + CONFLICT_1_OBJ: Object.freeze({ + type: 'sharedtype', + id: 'conflict_1', + expectedNamespaces: EACH_SPACE, + }), + CONFLICT_2A_OBJ: Object.freeze({ + type: 'sharedtype', + id: 'conflict_2a', + originId: 'conflict_2', + expectedNamespaces: EACH_SPACE, + }), + CONFLICT_2B_OBJ: Object.freeze({ + type: 'sharedtype', + id: 'conflict_2b', + originId: 'conflict_2', + expectedNamespaces: EACH_SPACE, + }), + CONFLICT_3_OBJ: Object.freeze({ + type: 'sharedtype', + id: 'conflict_3', + expectedNamespaces: EACH_SPACE, + }), + CONFLICT_4A_OBJ: Object.freeze({ + type: 'sharedtype', + id: 'conflict_4a', + originId: 'conflict_4', + expectedNamespaces: EACH_SPACE, + }), +}); diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 595986c08efc1d..9d4b5e80e9c3db 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -6,7 +6,6 @@ import expect from '@kbn/expect'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { SAVED_OBJECT_TEST_CASES as CASES } from './saved_object_test_cases'; import { SPACES } from './spaces'; import { AUTHENTICATION } from './authentication'; import { TestCase, TestUser, ExpectResponseBody } from './types'; @@ -73,6 +72,28 @@ export const getTestTitle = ( return `${list.join(' and ')}`; }; +export const isUserAuthorizedAtSpace = (user: TestUser | undefined, namespace: string) => + !user || user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(namespace); + +export const getRedactedNamespaces = ( + user: TestUser | undefined, + namespaces: string[] | undefined +) => namespaces?.map((x) => (isUserAuthorizedAtSpace(user, x) ? x : '?')).sort(namespaceComparator); +function namespaceComparator(a: string, b: string) { + // namespaces get sorted so that they're all in alphabetical order, and unknown ones appear at the end + // this is to prevent information disclosure + if (a === '?' && b !== '?') { + return 1; + } else if (b === '?' && a !== '?') { + return -1; + } else if (a > b) { + return 1; + } else if (a < b) { + return -1; + } + return 0; +} + export const testCaseFailures = { // test suites need explicit return types for number primitives fail400: (condition?: boolean): { failure?: 400 } => @@ -150,7 +171,7 @@ export const expectResponses = { } }, /** - * Additional assertions that we use in `bulk_create` and `create` to ensure that + * Additional assertions that we use in `import` and `resolve_import_errors` to ensure that * newly-created (or overwritten) objects don't have unexpected properties */ successCreated: async (es: any, spaceId: string, type: string, id: string) => { @@ -161,26 +182,6 @@ export const expectResponses = { id: `${expectedSpacePrefix}${type}:${id}`, index: '.kibana', }); - const { namespace: actualNamespace, namespaces: actualNamespaces } = savedObject._source; - if (isNamespaceUndefined) { - expect(actualNamespace).to.eql(undefined); - } else { - expect(actualNamespace).to.eql(spaceId); - } - if (isMultiNamespace(type)) { - if (['conflict_1', 'conflict_2a', 'conflict_2b', 'conflict_3', 'conflict_4a'].includes(id)) { - expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]); - } else if (id === CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1.id) { - expect(actualNamespaces).to.eql([DEFAULT_SPACE_ID, SPACE_1_ID]); - } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_1.id) { - expect(actualNamespaces).to.eql([SPACE_1_ID]); - } else if (id === CASES.MULTI_NAMESPACE_ONLY_SPACE_2.id) { - expect(actualNamespaces).to.eql([SPACE_2_ID]); - } else { - // newly created in this space - expect(actualNamespaces).to.eql([spaceId]); - } - } return savedObject; }, }; diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts index 56e6a992b6b626..b52a84f352999b 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/types.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/types.ts @@ -21,6 +21,7 @@ export interface TestSuite { export interface TestCase { type: string; id: string; + expectedNamespaces?: string[]; failure?: 400 | 403 | 404 | 409; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index e3163ef77d4279..b1608946b8e62f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -14,8 +14,9 @@ import { expectResponses, getUrlPrefix, getTestTitle, + getRedactedNamespaces, } from '../lib/saved_object_test_utils'; -import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; export interface BulkCreateTestDefinition extends TestDefinition { request: Array<{ type: string; id: string }>; @@ -33,7 +34,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); -export const TEST_CASES = Object.freeze({ +export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, @@ -45,7 +46,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: const expectResponseBody = ( testCases: BulkCreateTestCase | BulkCreateTestCase[], statusCode: 200 | 403, - spaceId = SPACES.DEFAULT.spaceId + user?: TestUser ): ExpectResponseBody => async (response: Record) => { const testCaseArray = Array.isArray(testCases) ? testCases : [testCases]; if (statusCode === 403) { @@ -70,7 +71,8 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: await expectResponses.permitted(object, testCase); if (!testCase.failure) { expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); - await expectResponses.successCreated(es, spaceId, object.type, object.id); + const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces); + expect(object.namespaces).to.eql(redactedNamespaces); } } } @@ -81,6 +83,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: overwrite: boolean, options?: { spaceId?: string; + user?: TestUser; singleRequest?: boolean; responseBodyOverride?: ExpectResponseBody; } @@ -95,8 +98,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: request: [createRequest(x)], responseStatusCode, responseBody: - options?.responseBodyOverride || - expectResponseBody(x, responseStatusCode, options?.spaceId), + options?.responseBodyOverride || expectResponseBody(x, responseStatusCode, options?.user), overwrite, })); } @@ -108,7 +110,7 @@ export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: responseStatusCode, responseBody: options?.responseBodyOverride || - expectResponseBody(cases, responseStatusCode, options?.spaceId), + expectResponseBody(cases, responseStatusCode, options?.user), overwrite, }, ]; diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index 8de54fe499c071..71ece1265347c5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -25,7 +25,10 @@ export interface BulkGetTestCase extends TestCase { } const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); -export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +export const TEST_CASES: Record = Object.freeze({ + ...CASES, + DOES_NOT_EXIST, +}); export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('bulk_get'); diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index 2e3c55f029d297..c3020b2da32197 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -24,7 +24,10 @@ const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); -export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +export const TEST_CASES: Record = Object.freeze({ + ...CASES, + DOES_NOT_EXIST, +}); const createRequest = ({ type, id, namespace }: BulkUpdateTestCase) => ({ type, diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 2a5ab696c4f53d..7e28d5ed9ed94f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -13,8 +13,9 @@ import { expectResponses, getUrlPrefix, getTestTitle, + getRedactedNamespaces, } from '../lib/saved_object_test_utils'; -import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; export interface CreateTestDefinition extends TestDefinition { request: { type: string; id: string }; @@ -33,7 +34,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); -export const TEST_CASES = Object.freeze({ +export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, @@ -44,7 +45,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe const expectForbidden = expectResponses.forbiddenTypes('create'); const expectResponseBody = ( testCase: CreateTestCase, - spaceId = SPACES.DEFAULT.spaceId + user?: TestUser ): ExpectResponseBody => async (response: Record) => { if (testCase.failure === 403) { await expectForbidden(testCase.type)(response); @@ -54,7 +55,8 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe await expectResponses.permitted(object, testCase); if (!testCase.failure) { expect(object.attributes[NEW_ATTRIBUTE_KEY]).to.eql(NEW_ATTRIBUTE_VAL); - await expectResponses.successCreated(es, spaceId, object.type, object.id); + const redactedNamespaces = getRedactedNamespaces(user, testCase.expectedNamespaces); + expect(object.namespaces).to.eql(redactedNamespaces); } } }; @@ -64,6 +66,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe overwrite: boolean, options?: { spaceId?: string; + user?: TestUser; responseBodyOverride?: ExpectResponseBody; } ): CreateTestDefinition[] => { @@ -76,7 +79,7 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe title: getTestTitle(x), responseStatusCode: x.failure ?? 200, request: createRequest(x), - responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.spaceId), + responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.user), overwrite, })); }; diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 3179b1b0c9ac5d..228e7977f99ac0 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -25,7 +25,10 @@ export interface DeleteTestCase extends TestCase { } const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); -export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +export const TEST_CASES: Record = Object.freeze({ + ...CASES, + DOES_NOT_EXIST, +}); export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('delete'); diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 4a8eff1fb380c0..4eb967a952c604 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -30,7 +30,10 @@ export interface ExportTestCase { type: string; id?: string; successResult?: SuccessResult | SuccessResult[]; - failure?: 400 | 403; + failure?: { + statusCode: 200 | 400 | 403; // if the user searches for only types they are not authorized for, they will get an empty 200 result + reason: 'unauthorized' | 'bad_request'; + }; } // additional sharedtype objects that exist but do not have common test cases defined @@ -90,41 +93,45 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase type: 'globaltype', successResult: CASES.NAMESPACE_AGNOSTIC, }, - hiddenObject: { title: 'hidden object', ...CASES.HIDDEN, failure: 400 }, - hiddenType: { title: 'hidden type', type: 'hiddentype', failure: 400 }, + hiddenObject: { + title: 'hidden object', + ...CASES.HIDDEN, + failure: { statusCode: 400, reason: 'bad_request' }, + }, + hiddenType: { + title: 'hidden type', + type: 'hiddentype', + failure: { statusCode: 400, reason: 'bad_request' }, + }, }); export const createRequest = ({ type, id }: ExportTestCase) => id ? { objects: [{ type, id }] } : { type }; -const getTestTitle = ({ failure, title }: ExportTestCase) => { - let description = 'success'; - if (failure === 400) { - description = 'bad request'; - } else if (failure === 403) { - description = 'forbidden'; - } - return `${description} ["${title}"]`; -}; +const getTestTitle = ({ failure, title }: ExportTestCase) => + `${failure?.reason || 'success'} ["${title}"]`; + +const EMPTY_RESULT = { exportedCount: 0, missingRefCount: 0, missingReferences: [] }; export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get'); - const expectForbiddenFind = expectResponses.forbiddenTypes('find'); const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async ( response: Record ) => { const { type, id, successResult = { type, id } as SuccessResult, failure } = testCase; - if (failure === 403) { - // In export only, the API uses "bulk_get" or "find" depending on the parameters it receives. - // The best that could be done here is to have an if statement to ensure at least one of the - // two errors has been thrown. - if (id) { + if (failure?.reason === 'unauthorized') { + // In export only, the API uses "bulkGet" or "find" depending on the parameters it receives. + if (failure.statusCode === 403) { + // "bulkGet" was unauthorized, which returns a forbidden error await expectForbiddenBulkGet(type)(response); + } else if (failure.statusCode === 200) { + // "find" was unauthorized, which returns an empty result + expect(response.body).not.to.have.property('error'); + expect(response.text).to.equal(JSON.stringify(EMPTY_RESULT)); } else { - await expectForbiddenFind(type)(response); + throw new Error(`Unexpected failure status code: ${failure.statusCode}`); } - } else if (failure === 400) { - // 400 + } else if (failure?.reason === 'bad_request') { expect(response.body.error).to.eql('Bad Request'); - expect(response.body.statusCode).to.eql(failure); + expect(response.body.statusCode).to.eql(failure.statusCode); if (id) { expect(response.body.message).to.eql( `Trying to export object(s) with non-exportable types: ${type}:${id}` @@ -132,6 +139,8 @@ export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest { let cases = Array.isArray(testCases) ? testCases : [testCases]; - if (forbidden) { + if (failure) { // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403 })); + cases = cases.map((x) => ({ ...x, failure })); } return cases.map((x) => ({ title: getTestTitle(x), - responseStatusCode: x.failure ?? 200, + responseStatusCode: x.failure?.statusCode ?? 200, request: createRequest(x), responseBody: options?.responseBodyOverride || expectResponseBody(x), })); diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index bab4a4d88534a8..381306f8101223 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -7,10 +7,13 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import querystring from 'querystring'; -import { Assign } from '@kbn/utility-types'; -import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; +import { SAVED_OBJECT_TEST_CASES, CONFLICT_TEST_CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; +import { + getUrlPrefix, + isUserAuthorizedAtSpace, + getRedactedNamespaces, +} from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; const { @@ -22,80 +25,34 @@ export interface FindTestDefinition extends TestDefinition { } export type FindTestSuite = TestSuite; -type FindSavedObjectCase = Assign; - export interface FindTestCase { title: string; query: string; successResult?: { - savedObjects?: FindSavedObjectCase | FindSavedObjectCase[]; + savedObjects?: TestCase | TestCase[]; page?: number; perPage?: number; total?: number; }; failure?: { - statusCode: 400 | 403; - reason: - | 'forbidden_types' - | 'forbidden_namespaces' - | 'cross_namespace_not_permitted' - | 'bad_request'; + statusCode: 200 | 400; // if the user searches for types and/or namespaces they are not authorized for, they will get a 200 result with those types/namespaces omitted + reason: 'unauthorized' | 'cross_namespace_not_permitted' | 'bad_request'; }; } -// additional sharedtype objects that exist but do not have common test cases defined -const CONFLICT_1_OBJ = Object.freeze({ - type: 'sharedtype', - id: 'conflict_1', - namespaces: ['default', 'space_1', 'space_2'], -}); -const CONFLICT_2A_OBJ = Object.freeze({ - type: 'sharedtype', - id: 'conflict_2a', - originId: 'conflict_2', - namespaces: ['default', 'space_1', 'space_2'], -}); -const CONFLICT_2B_OBJ = Object.freeze({ - type: 'sharedtype', - id: 'conflict_2b', - originId: 'conflict_2', - namespaces: ['default', 'space_1', 'space_2'], -}); -const CONFLICT_3_OBJ = Object.freeze({ - type: 'sharedtype', - id: 'conflict_3', - namespaces: ['default', 'space_1', 'space_2'], -}); -const CONFLICT_4A_OBJ = Object.freeze({ - type: 'sharedtype', - id: 'conflict_4a', - originId: 'conflict_4', - namespaces: ['default', 'space_1', 'space_2'], -}); - const TEST_CASES = [ - { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: ['space_2'] }, - { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespaces: ['default', 'space_1'] }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespaces: ['space_1'] }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespaces: ['space_2'] }, - { ...CASES.NAMESPACE_AGNOSTIC, namespaces: undefined }, - { ...CASES.HIDDEN, namespaces: undefined }, + ...Object.values(SAVED_OBJECT_TEST_CASES), + ...Object.values(CONFLICT_TEST_CASES), ]; -expect(TEST_CASES.length).to.eql( - Object.values(CASES).length, - 'Unhandled test cases in `find` suite' -); - export const getTestCases = ( { currentSpace, crossSpaceSearch }: { currentSpace?: string; crossSpaceSearch?: string[] } = { currentSpace: undefined, crossSpaceSearch: undefined, } ) => { - const crossSpaceIds = crossSpaceSearch?.filter((s) => s !== (currentSpace ?? 'default')) ?? []; + const crossSpaceIds = + crossSpaceSearch?.filter((s) => s !== (currentSpace ?? DEFAULT_SPACE_ID)) ?? []; // intentionally exclude the current space const isCrossSpaceSearch = crossSpaceIds.length > 0; const isWildcardSearch = crossSpaceIds.includes('*'); @@ -104,7 +61,7 @@ export const getTestCases = ( : ''; const buildTitle = (title: string) => - crossSpaceSearch ? `${title} (cross-space ${isWildcardSearch ? 'with wildcard' : ''})` : title; + crossSpaceSearch ? `${title} (cross-space${isWildcardSearch ? ' with wildcard' : ''})` : title; type CasePredicate = (testCase: TestCase) => boolean; const getExpectedSavedObjects = (predicate: CasePredicate) => { @@ -117,13 +74,16 @@ export const getTestCases = ( return TEST_CASES.filter((t) => { const hasOtherNamespaces = - Array.isArray(t.namespaces) && - t.namespaces!.some((ns) => ns !== (currentSpace ?? 'default')); + !t.expectedNamespaces || // namespace-agnostic types do not have an expectedNamespaces field + t.expectedNamespaces.some((ns) => ns !== (currentSpace ?? DEFAULT_SPACE_ID)); return hasOtherNamespaces && predicate(t); }); } return TEST_CASES.filter( - (t) => (!t.namespaces || t.namespaces.includes(currentSpace ?? 'default')) && predicate(t) + (t) => + (!t.expectedNamespaces || + t.expectedNamespaces.includes(currentSpace ?? DEFAULT_SPACE_ID)) && + predicate(t) ); }; @@ -140,19 +100,13 @@ export const getTestCases = ( query: `type=sharedtype&fields=title${namespacesQueryParam}`, successResult: { // expected depends on which spaces the user is authorized against... - savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype').concat( - CONFLICT_1_OBJ, - CONFLICT_2A_OBJ, - CONFLICT_2B_OBJ, - CONFLICT_3_OBJ, - CONFLICT_4A_OBJ - ), + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), }, } as FindTestCase, namespaceAgnosticType: { title: buildTitle('find namespace-agnostic type'), query: `type=globaltype&fields=title${namespacesQueryParam}`, - successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + successResult: { savedObjects: SAVED_OBJECT_TEST_CASES.NAMESPACE_AGNOSTIC }, } as FindTestCase, hiddenType: { title: buildTitle('find hidden type'), @@ -162,6 +116,15 @@ export const getTestCases = ( title: buildTitle('find unknown type'), query: `type=wigwags${namespacesQueryParam}`, } as FindTestCase, + eachType: { + title: buildTitle('find each type'), + query: `type=isolatedtype&type=sharedtype&type=globaltype&type=hiddentype&type=wigwags${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => + ['isolatedtype', 'sharedtype', 'globaltype'].includes(t.type) + ), + }, + } as FindTestCase, pageBeyondTotal: { title: buildTitle('find page beyond total'), query: `type=isolatedtype&page=100&per_page=100${namespacesQueryParam}`, @@ -179,7 +142,7 @@ export const getTestCases = ( filterWithNamespaceAgnosticType: { title: buildTitle('filter with namespace-agnostic type'), query: `type=globaltype&filter=globaltype.attributes.title:*global*${namespacesQueryParam}`, - successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + successResult: { savedObjects: SAVED_OBJECT_TEST_CASES.NAMESPACE_AGNOSTIC }, } as FindTestCase, filterWithHiddenType: { title: buildTitle('filter with hidden type'), @@ -200,49 +163,48 @@ export const getTestCases = ( }; }; +function objectComparator(a: { id: string }, b: { id: string }) { + return a.id > b.id ? 1 : a.id < b.id ? -1 : 0; +} + export const createRequest = ({ query }: FindTestCase) => ({ query }); -const getTestTitle = ({ failure, title }: FindTestCase) => { - let description = 'success'; - if (failure?.statusCode === 400) { - description = 'bad request'; - } else if (failure?.statusCode === 403) { - description = 'forbidden'; - } - return `${description} ["${title}"]`; -}; +const getTestTitle = ({ failure, title }: FindTestCase) => + `${failure?.reason || 'success'} ["${title}"]`; export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbiddenTypes = expectResponses.forbiddenTypes('find'); - const expectForbiddeNamespaces = expectResponses.forbiddenSpaces; const expectResponseBody = ( testCase: FindTestCase, user?: TestUser ): ExpectResponseBody => async (response: Record) => { const { failure, successResult = {}, query } = testCase; const parsedQuery = querystring.parse(query); - if (failure?.statusCode === 403) { - if (failure?.reason === 'forbidden_types') { - const type = parsedQuery.type; - await expectForbiddenTypes(type)(response); - } else if (failure?.reason === 'forbidden_namespaces') { - await expectForbiddeNamespaces(response); + if (failure?.statusCode === 200) { + if (failure?.reason === 'unauthorized') { + // if the user is completely unauthorized, they will receive an empty response body + const expected = { + page: parsedQuery.page || 1, + per_page: parsedQuery.per_page || 20, + total: 0, + saved_objects: [], + }; + expect(response.body).to.eql(expected); } else { - throw new Error(`Unexpected failure reason: ${failure?.reason}`); + throw new Error(`Unexpected failure reason: ${failure.reason}`); } } else if (failure?.statusCode === 400) { - if (failure?.reason === 'bad_request') { + if (failure.reason === 'bad_request') { const type = (parsedQuery.filter as string).split('.')[0]; expect(response.body.error).to.eql('Bad Request'); - expect(response.body.statusCode).to.eql(failure?.statusCode); + expect(response.body.statusCode).to.eql(failure.statusCode); expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`); - } else if (failure?.reason === 'cross_namespace_not_permitted') { + } else if (failure.reason === 'cross_namespace_not_permitted') { expect(response.body.error).to.eql('Bad Request'); - expect(response.body.statusCode).to.eql(failure?.statusCode); + expect(response.body.statusCode).to.eql(failure.statusCode); expect(response.body.message).to.eql( `_find across namespaces is not permitted when the Spaces plugin is disabled.: Bad Request` ); } else { - throw new Error(`Unexpected failure reason: ${failure?.reason}`); + throw new Error(`Unexpected failure reason: ${failure.reason}`); } } else { // 2xx @@ -251,11 +213,8 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects]; const authorizedSavedObjects = savedObjectsArray.filter( (so) => - !user || - !so.namespaces || - so.namespaces.some( - (ns) => user.authorizedAtSpaces.includes(ns) || user.authorizedAtSpaces.includes('*') - ) + !so.expectedNamespaces || + so.expectedNamespaces.some((x) => isUserAuthorizedAtSpace(user, x)) ); expect(response.body.page).to.eql(page); expect(response.body.per_page).to.eql(perPage); @@ -265,16 +224,17 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) expect(response.body.total).to.eql(total || authorizedSavedObjects.length); } - authorizedSavedObjects.sort((s1, s2) => (s1.id < s2.id ? -1 : 1)); - response.body.saved_objects.sort((s1: any, s2: any) => (s1.id < s2.id ? -1 : 1)); + authorizedSavedObjects.sort(objectComparator); + response.body.saved_objects.sort(objectComparator); for (let i = 0; i < authorizedSavedObjects.length; i++) { const object = response.body.saved_objects[i]; - const { type: expectedType, id: expectedId } = authorizedSavedObjects[i]; - expect(object.type).to.eql(expectedType); - expect(object.id).to.eql(expectedId); + const expected = authorizedSavedObjects[i]; + const expectedNamespaces = getRedactedNamespaces(user, expected.expectedNamespaces); + expect(object.type).to.eql(expected.type); + expect(object.id).to.eql(expected.id); expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); - expect(object.namespaces).to.eql(object.namespaces); + expect(object.namespaces).to.eql(expectedNamespaces); // don't test attributes, version, or references } } diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index fb03cd548d41a8..8d8938b5ee79f2 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -21,7 +21,7 @@ export type GetTestSuite = TestSuite; export type GetTestCase = TestCase; const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); -export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +export const TEST_CASES: Record = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('get'); diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index 5036d7b2008810..b0d0b4f8a815a5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -36,7 +36,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_4a, originId: conflict_4 // using the seven conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios const CID = 'conflict_'; -export const TEST_CASES = Object.freeze({ +export const TEST_CASES: Record = Object.freeze({ ...CASES, CONFLICT_1_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1` }), CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', id: `${CID}1a`, originId: `${CID}1` }), diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index 6d294aed9b4de7..02fa614ac2b55d 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -37,7 +37,7 @@ const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; // * id: conflict_3 // * id: conflict_4a, originId: conflict_4 // using the five conflict test case objects below, we can exercise various permutations of exact/inexact/ambiguous conflict scenarios -export const TEST_CASES = Object.freeze({ +export const TEST_CASES: Record = Object.freeze({ ...CASES, CONFLICT_1A_OBJ: Object.freeze({ type: 'sharedtype', diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts index 82f4699babf462..19921a82b2eb44 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -28,7 +28,10 @@ const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, const NEW_ATTRIBUTE_VAL = `Updated attribute value ${Date.now()}`; const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); -export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); +export const TEST_CASES: Record = Object.freeze({ + ...CASES, + DOES_NOT_EXIST, +}); export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('update'); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 0cc5969e2b7ab0..93ae439d011667 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -26,13 +26,23 @@ const unresolvableConflict = (condition?: boolean) => const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result + const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value const normalTypes = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + expectedNamespaces, }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -49,8 +59,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...unresolvableConflict(spaceId !== SPACE_2_ID), }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; @@ -68,22 +78,28 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean, spaceId: string) => { + const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); // use singleRequest to reduce execution time and/or test combined cases return { - unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), authorized: [ - createTestDefinitions(normalTypes, false, overwrite, { spaceId, singleRequest: true }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + createTestDefinitions(normalTypes, false, overwrite, { + spaceId, + user, + singleRequest: true, + }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), createTestDefinitions(allTypes, true, overwrite, { spaceId, + user, singleRequest: true, responseBodyOverride: expectForbidden(['hiddentype']), }), ].flat(), superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, + user, singleRequest: true, }), }; @@ -93,7 +109,6 @@ export default function ({ getService }: FtrProviderContext) { getTestScenarios([false, true]).securityAndSpaces.forEach( ({ spaceId, users, modifier: overwrite }) => { const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; - const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -106,11 +121,14 @@ export default function ({ getService }: FtrProviderContext) { users.readAtSpace, users.allAtOtherSpace, ].forEach((user) => { + const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { + const { authorized } = createTests(overwrite!, spaceId, user); _addTests(user, authorized); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } ); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index f81488603dc83e..7353dafb5e1b5b 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -24,13 +24,23 @@ const { fail400, fail409 } = testCaseFailures; const createTestCases = (overwrite: boolean, spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result + const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value const normalTypes = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + expectedNamespaces, }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -38,8 +48,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; @@ -53,15 +63,15 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); - const createTests = (overwrite: boolean, spaceId: string) => { + const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); return { - unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId }), + unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), authorized: [ - createTestDefinitions(normalTypes, false, overwrite, { spaceId }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId }), + createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), ].flat(), - superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId }), + superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }), }; }; @@ -69,7 +79,6 @@ export default function ({ getService }: FtrProviderContext) { getTestScenarios([false, true]).securityAndSpaces.forEach( ({ spaceId, users, modifier: overwrite }) => { const suffix = ` within the ${spaceId} space${overwrite ? ' with overwrite enabled' : ''}`; - const { unauthorized, authorized, superuser } = createTests(overwrite!, spaceId); const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, spaceId, tests }); }; @@ -82,11 +91,14 @@ export default function ({ getService }: FtrProviderContext) { users.readAtSpace, users.allAtOtherSpace, ].forEach((user) => { + const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { + const { authorized } = createTests(overwrite!, spaceId, user); _addTests(user, authorized); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } ); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts index c581a1757565e7..be3906209032f8 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/export.ts @@ -15,17 +15,23 @@ import { const createTestCases = (spaceId: string) => { const cases = getTestCases(spaceId); - const exportableTypes = [ + const exportableObjects = [ cases.singleNamespaceObject, - cases.singleNamespaceType, cases.multiNamespaceObject, - cases.multiNamespaceType, cases.namespaceAgnosticObject, + ]; + const exportableTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, cases.namespaceAgnosticType, ]; - const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; - const allTypes = exportableTypes.concat(nonExportableTypes); - return { exportableTypes, nonExportableTypes, allTypes }; + const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; + const allObjectsAndTypes = [ + exportableObjects, + exportableTypes, + nonExportableObjectsAndTypes, + ].flat(); + return { exportableObjects, exportableTypes, nonExportableObjectsAndTypes, allObjectsAndTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -34,13 +40,19 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); const createTests = (spaceId: string) => { - const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(spaceId); + const { + exportableObjects, + exportableTypes, + nonExportableObjectsAndTypes, + allObjectsAndTypes, + } = createTestCases(spaceId); return { unauthorized: [ - createTestDefinitions(exportableTypes, true), - createTestDefinitions(nonExportableTypes, false), + createTestDefinitions(exportableObjects, { statusCode: 403, reason: 'unauthorized' }), + createTestDefinitions(exportableTypes, { statusCode: 200, reason: 'unauthorized' }), // failure with empty result + createTestDefinitions(nonExportableObjectsAndTypes, false), ].flat(), - authorized: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allObjectsAndTypes, false), }; }; diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index 6ac77507df473c..afd4783fab7921 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -4,18 +4,30 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; +import { SPACES } from '../../common/lib/spaces'; +import { AUTHENTICATION } from '../../common/lib/authentication'; +import { + getTestScenarios, + isUserAuthorizedAtSpace, +} from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; -const createTestCases = (currentSpace: string, crossSpaceSearch: string[]) => { +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (currentSpace: string, crossSpaceSearch?: string[]) => { const cases = getTestCases({ currentSpace, crossSpaceSearch }); const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, cases.namespaceAgnosticType, + cases.eachType, cases.pageBeyondTotal, cases.unknownSearchField, cases.filterWithNamespaceAgnosticType, @@ -37,89 +49,72 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); const createTests = (spaceId: string, user: TestUser) => { - const currentSpaceCases = createTestCases(spaceId, []); + const currentSpaceCases = createTestCases(spaceId); - const explicitCrossSpace = createTestCases(spaceId, ['default', 'space_1', 'space_2']); + const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const explicitCrossSpace = createTestCases(spaceId, EACH_SPACE); const wildcardCrossSpace = createTestCases(spaceId, ['*']); - if (user.username === 'elastic') { + if (user.username === AUTHENTICATION.SUPERUSER.username) { return { currentSpace: createTestDefinitions(currentSpaceCases.allTypes, false, { user }), - crossSpace: createTestDefinitions(explicitCrossSpace.allTypes, false, { user }), + crossSpace: [ + createTestDefinitions(explicitCrossSpace.allTypes, false, { user }), + createTestDefinitions(wildcardCrossSpace.allTypes, false, { user }), + ].flat(), }; } - const authorizedAtCurrentSpace = - user.authorizedAtSpaces.includes(spaceId) || user.authorizedAtSpaces.includes('*'); - - const authorizedExplicitCrossSpaces = ['default', 'space_1', 'space_2'].filter( - (s) => - user.authorizedAtSpaces.includes('*') || - (s !== spaceId && user.authorizedAtSpaces.includes(s)) + const isAuthorizedExplicitCrossSpaces = EACH_SPACE.some( + (s) => s !== spaceId && isUserAuthorizedAtSpace(user, s) ); - - const authorizedWildcardCrossSpaces = ['default', 'space_1', 'space_2'].filter( - (s) => user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(s) + const isAuthorizedWildcardCrossSpaces = EACH_SPACE.some((s) => + isUserAuthorizedAtSpace(user, s) ); - const explicitCrossSpaceDefinitions = - authorizedExplicitCrossSpaces.length > 0 - ? [ - createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }), - createTestDefinitions( - explicitCrossSpace.hiddenAndUnknownTypes, - { - statusCode: 403, - reason: 'forbidden_types', - }, - { user } - ), - ].flat() - : createTestDefinitions( - explicitCrossSpace.allTypes, - { - statusCode: 403, - reason: 'forbidden_namespaces', - }, + const explicitCrossSpaceDefinitions = isAuthorizedExplicitCrossSpaces + ? [ + createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }), + createTestDefinitions( + explicitCrossSpace.hiddenAndUnknownTypes, + { statusCode: 200, reason: 'unauthorized' }, { user } - ); - - const wildcardCrossSpaceDefinitions = - authorizedWildcardCrossSpaces.length > 0 - ? [ - createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }), - createTestDefinitions( - wildcardCrossSpace.hiddenAndUnknownTypes, - { - statusCode: 403, - reason: 'forbidden_types', - }, - { user } - ), - ].flat() - : createTestDefinitions( - wildcardCrossSpace.allTypes, - { - statusCode: 403, - reason: 'forbidden_namespaces', - }, + ), + ].flat() + : createTestDefinitions( + explicitCrossSpace.allTypes, + { statusCode: 200, reason: 'unauthorized' }, + { user } + ); + const wildcardCrossSpaceDefinitions = isAuthorizedWildcardCrossSpaces + ? [ + createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }), + createTestDefinitions( + wildcardCrossSpace.hiddenAndUnknownTypes, + { statusCode: 200, reason: 'unauthorized' }, { user } - ); + ), + ].flat() + : createTestDefinitions( + wildcardCrossSpace.allTypes, + { statusCode: 200, reason: 'unauthorized' }, + { user } + ); return { - currentSpace: authorizedAtCurrentSpace + currentSpace: isUserAuthorizedAtSpace(user, spaceId) ? [ createTestDefinitions(currentSpaceCases.normalTypes, false, { user, }), createTestDefinitions(currentSpaceCases.hiddenAndUnknownTypes, { - statusCode: 403, - reason: 'forbidden_types', + statusCode: 200, + reason: 'unauthorized', }), ].flat() : createTestDefinitions(currentSpaceCases.allTypes, { - statusCode: 403, - reason: 'forbidden_types', + statusCode: 200, + reason: 'unauthorized', }), crossSpace: [...explicitCrossSpaceDefinitions, ...wildcardCrossSpaceDefinitions], }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index 725120687c2313..cc2c5e2e7fc005 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -13,22 +14,26 @@ import { BulkCreateTestDefinition, } from '../../common/suites/bulk_create'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, +} = SPACES; const { fail400, fail409 } = testCaseFailures; const unresolvableConflict = () => ({ fail409Param: 'unresolvableConflict' }); const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result + const expectedNamespaces = [DEFAULT_SPACE_ID]; // newly created objects should have this `namespaces` array in their return value const normalTypes = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, - CASES.SINGLE_NAMESPACE_SPACE_1, - CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; @@ -46,27 +51,27 @@ export default function ({ getService }: FtrProviderContext) { esArchiver, supertest ); - const createTests = (overwrite: boolean) => { + const createTests = (overwrite: boolean, user: TestUser) => { const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); // use singleRequest to reduce execution time and/or test combined cases return { - unauthorized: createTestDefinitions(allTypes, true, overwrite), + unauthorized: createTestDefinitions(allTypes, true, overwrite, { user }), authorized: [ - createTestDefinitions(normalTypes, false, overwrite, { singleRequest: true }), - createTestDefinitions(hiddenType, true, overwrite), + createTestDefinitions(normalTypes, false, overwrite, { user, singleRequest: true }), + createTestDefinitions(hiddenType, true, overwrite, { user }), createTestDefinitions(allTypes, true, overwrite, { + user, singleRequest: true, responseBodyOverride: expectForbidden(['hiddentype']), }), ].flat(), - superuser: createTestDefinitions(allTypes, false, overwrite, { singleRequest: true }), + superuser: createTestDefinitions(allTypes, false, overwrite, { user, singleRequest: true }), }; }; describe('_bulk_create', () => { getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { const suffix = overwrite ? ' with overwrite enabled' : ''; - const { unauthorized, authorized, superuser } = createTests(overwrite!); const _addTests = (user: TestUser, tests: BulkCreateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, tests }); }; @@ -81,11 +86,14 @@ export default function ({ getService }: FtrProviderContext) { users.allAtSpace1, users.readAtSpace1, ].forEach((user) => { + const { unauthorized } = createTests(overwrite!, user); _addTests(user, unauthorized); }); [users.dualAll, users.allGlobally].forEach((user) => { + const { authorized } = createTests(overwrite!, user); _addTests(user, authorized); }); + const { superuser } = createTests(overwrite!, users.superuser); _addTests(users.superuser, superuser); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index 88d096f05d8466..b7c6ecef979bda 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -13,21 +14,25 @@ import { CreateTestDefinition, } from '../../common/suites/create'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, +} = SPACES; const { fail400, fail409 } = testCaseFailures; const createTestCases = (overwrite: boolean) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect // to receive an error; otherwise, we expect to receive a success result + const expectedNamespaces = [DEFAULT_SPACE_ID]; // newly created objects should have this `namespaces` array in their return value const normalTypes = [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, - CASES.SINGLE_NAMESPACE_SPACE_1, - CASES.SINGLE_NAMESPACE_SPACE_2, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; @@ -41,22 +46,21 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('legacyEs'); const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); - const createTests = (overwrite: boolean) => { + const createTests = (overwrite: boolean, user: TestUser) => { const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite); return { - unauthorized: createTestDefinitions(allTypes, true, overwrite), + unauthorized: createTestDefinitions(allTypes, true, overwrite, { user }), authorized: [ - createTestDefinitions(normalTypes, false, overwrite), - createTestDefinitions(hiddenType, true, overwrite), + createTestDefinitions(normalTypes, false, overwrite, { user }), + createTestDefinitions(hiddenType, true, overwrite, { user }), ].flat(), - superuser: createTestDefinitions(allTypes, false, overwrite), + superuser: createTestDefinitions(allTypes, false, overwrite, { user }), }; }; describe('_create', () => { getTestScenarios([false, true]).security.forEach(({ users, modifier: overwrite }) => { const suffix = overwrite ? ' with overwrite enabled' : ''; - const { unauthorized, authorized, superuser } = createTests(overwrite!); const _addTests = (user: TestUser, tests: CreateTestDefinition[]) => { addTests(`${user.description}${suffix}`, { user, tests }); }; @@ -71,11 +75,14 @@ export default function ({ getService }: FtrProviderContext) { users.allAtSpace1, users.readAtSpace1, ].forEach((user) => { + const { unauthorized } = createTests(overwrite!, user); _addTests(user, unauthorized); }); [users.dualAll, users.allGlobally].forEach((user) => { + const { authorized } = createTests(overwrite!, user); _addTests(user, authorized); }); + const { superuser } = createTests(overwrite!, users.superuser); _addTests(users.superuser, superuser); }); }); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts index 99babf683ccfa2..ea1ed56921d22e 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/export.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/export.ts @@ -15,17 +15,23 @@ import { const createTestCases = () => { const cases = getTestCases(); - const exportableTypes = [ + const exportableObjects = [ cases.singleNamespaceObject, - cases.singleNamespaceType, cases.multiNamespaceObject, - cases.multiNamespaceType, cases.namespaceAgnosticObject, + ]; + const exportableTypes = [ + cases.singleNamespaceType, + cases.multiNamespaceType, cases.namespaceAgnosticType, ]; - const nonExportableTypes = [cases.hiddenObject, cases.hiddenType]; - const allTypes = exportableTypes.concat(nonExportableTypes); - return { exportableTypes, nonExportableTypes, allTypes }; + const nonExportableObjectsAndTypes = [cases.hiddenObject, cases.hiddenType]; + const allObjectsAndTypes = [ + exportableObjects, + exportableTypes, + nonExportableObjectsAndTypes, + ].flat(); + return { exportableObjects, exportableTypes, nonExportableObjectsAndTypes, allObjectsAndTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -34,13 +40,19 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = exportTestSuiteFactory(esArchiver, supertest); const createTests = () => { - const { exportableTypes, nonExportableTypes, allTypes } = createTestCases(); + const { + exportableObjects, + exportableTypes, + nonExportableObjectsAndTypes, + allObjectsAndTypes, + } = createTestCases(); return { unauthorized: [ - createTestDefinitions(exportableTypes, true), - createTestDefinitions(nonExportableTypes, false), + createTestDefinitions(exportableObjects, { statusCode: 403, reason: 'unauthorized' }), + createTestDefinitions(exportableTypes, { statusCode: 200, reason: 'unauthorized' }), // failure with empty result + createTestDefinitions(nonExportableObjectsAndTypes, false), ].flat(), - authorized: createTestDefinitions(allTypes, false), + authorized: createTestDefinitions(allObjectsAndTypes, false), }; }; diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 3a435119436ca1..aa18f32600949a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -4,18 +4,27 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SPACES } from '../../common/lib/spaces'; +import { AUTHENTICATION } from '../../common/lib/authentication'; import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; -const createTestCases = (crossSpaceSearch: string[]) => { +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (crossSpaceSearch?: string[]) => { const cases = getTestCases({ crossSpaceSearch }); const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, cases.namespaceAgnosticType, + cases.eachType, cases.pageBeyondTotal, cases.unknownSearchField, cases.filterWithNamespaceAgnosticType, @@ -37,46 +46,35 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); const createTests = (user: TestUser) => { - const defaultCases = createTestCases([]); - const crossSpaceCases = createTestCases(['default', 'space_1', 'space_2']); + const defaultCases = createTestCases(); + const crossSpaceCases = createTestCases([DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]); - if (user.username === 'elastic') { + if (user.username === AUTHENTICATION.SUPERUSER.username) { return { defaultCases: createTestDefinitions(defaultCases.allTypes, false, { user }), crossSpace: createTestDefinitions( crossSpaceCases.allTypes, - { - statusCode: 400, - reason: 'cross_namespace_not_permitted', - }, + { statusCode: 400, reason: 'cross_namespace_not_permitted' }, { user } ), }; } - const authorizedGlobally = user.authorizedAtSpaces.includes('*'); + const isAuthorizedGlobally = user.authorizedAtSpaces.includes('*'); return { - defaultCases: authorizedGlobally + defaultCases: isAuthorizedGlobally ? [ - createTestDefinitions(defaultCases.normalTypes, false, { - user, - }), + createTestDefinitions(defaultCases.normalTypes, false, { user }), createTestDefinitions(defaultCases.hiddenAndUnknownTypes, { - statusCode: 403, - reason: 'forbidden_types', + statusCode: 200, + reason: 'unauthorized', }), ].flat() - : createTestDefinitions(defaultCases.allTypes, { - statusCode: 403, - reason: 'forbidden_types', - }), + : createTestDefinitions(defaultCases.allTypes, { statusCode: 200, reason: 'unauthorized' }), crossSpace: createTestDefinitions( crossSpaceCases.allTypes, - { - statusCode: 400, - reason: 'cross_namespace_not_permitted', - }, + { statusCode: 400, reason: 'cross_namespace_not_permitted' }, { user } ), }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index 74fade39bf7a58..ef47b09eddbc83 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -19,36 +19,48 @@ const { fail400, fail409 } = testCaseFailures; const unresolvableConflict = (condition?: boolean) => condition !== false ? { fail409Param: 'unresolvableConflict' } : {}; -const createTestCases = (overwrite: boolean, spaceId: string) => [ +const createTestCases = (overwrite: boolean, spaceId: string) => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), - }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { - ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), - ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), - }, - { - ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, - ...fail409(!overwrite || spaceId !== SPACE_1_ID), - ...unresolvableConflict(spaceId !== SPACE_1_ID), - }, - { - ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, - ...fail409(!overwrite || spaceId !== SPACE_2_ID), - ...unresolvableConflict(spaceId !== SPACE_2_ID), - }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value + return [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + expectedNamespaces, + }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + ...unresolvableConflict(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, + ...fail409(!overwrite || spaceId !== SPACE_1_ID), + ...unresolvableConflict(spaceId !== SPACE_1_ID), + }, + { + ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, + ...fail409(!overwrite || spaceId !== SPACE_2_ID), + ...unresolvableConflict(spaceId !== SPACE_2_ID), + }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 1040f7fd81ddee..10e57b4db82dc7 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -16,27 +16,39 @@ const { } = SPACES; const { fail400, fail409 } = testCaseFailures; -const createTestCases = (overwrite: boolean, spaceId: string) => [ +const createTestCases = (overwrite: boolean, spaceId: string) => { // for each outcome, if failure !== undefined then we expect to receive // an error; otherwise, we expect to receive a success result - { - ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), - }, - { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, - { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, - { - ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), - }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, - { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, - { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, - { ...CASES.HIDDEN, ...fail400() }, - CASES.NEW_SINGLE_NAMESPACE_OBJ, - CASES.NEW_MULTI_NAMESPACE_OBJ, - CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, -]; + const expectedNamespaces = [spaceId]; // newly created objects should have this `namespaces` array in their return value + return [ + { + ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, + ...fail409(!overwrite && spaceId === DEFAULT_SPACE_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_1, + ...fail409(!overwrite && spaceId === SPACE_1_ID), + expectedNamespaces, + }, + { + ...CASES.SINGLE_NAMESPACE_SPACE_2, + ...fail409(!overwrite && spaceId === SPACE_2_ID), + expectedNamespaces, + }, + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), + }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(!overwrite || spaceId !== SPACE_1_ID) }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(!overwrite || spaceId !== SPACE_2_ID) }, + { ...CASES.NAMESPACE_AGNOSTIC, ...fail409(!overwrite) }, + { ...CASES.HIDDEN, ...fail400() }, + { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, + { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, + CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + ]; +}; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertestWithoutAuth'); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index 1d46985916cd50..c6779402d3291a 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -4,11 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SPACES } from '../../common/lib/spaces'; import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; -const createTestCases = (spaceId: string, crossSpaceSearch: string[]) => { +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (spaceId: string, crossSpaceSearch?: string[]) => { const cases = getTestCases({ currentSpace: spaceId, crossSpaceSearch }); return Object.values(cases); }; @@ -18,15 +25,19 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); - const createTests = (spaceId: string, crossSpaceSearch: string[]) => { + const createTests = (spaceId: string, crossSpaceSearch?: string[]) => { const testCases = createTestCases(spaceId, crossSpaceSearch); return createTestDefinitions(testCases, false); }; describe('_find', () => { getTestScenarios().spaces.forEach(({ spaceId }) => { - const currentSpaceTests = createTests(spaceId, []); - const explicitCrossSpaceTests = createTests(spaceId, ['default', 'space_1', 'space_2']); + const currentSpaceTests = createTests(spaceId); + const explicitCrossSpaceTests = createTests(spaceId, [ + DEFAULT_SPACE_ID, + SPACE_1_ID, + SPACE_2_ID, + ]); const wildcardCrossSpaceTests = createTests(spaceId, ['*']); addTests(`within the ${spaceId} space`, { spaceId, From 0238206ace13c57cd5de58d0d0b14df38d36043b Mon Sep 17 00:00:00 2001 From: Luca Belluccini Date: Tue, 22 Sep 2020 18:44:30 +0200 Subject: [PATCH 08/15] [DOC] Clarify supported realms when accessing remote monitoring clusters (#77938) Co-authored-by: lcawl --- docs/user/monitoring/viewing-metrics.asciidoc | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/user/monitoring/viewing-metrics.asciidoc b/docs/user/monitoring/viewing-metrics.asciidoc index f35caea025cdd5..0c48e3b7d011d7 100644 --- a/docs/user/monitoring/viewing-metrics.asciidoc +++ b/docs/user/monitoring/viewing-metrics.asciidoc @@ -13,13 +13,19 @@ At a minimum, you must have monitoring data for the {es} production cluster. Once that data exists, {kib} can display monitoring data for other products in the cluster. +TIP: If you use a separate monitoring cluster to store the monitoring data, it +is strongly recommended that you use a separate {kib} instance to view it. If +you log in to {kib} using SAML, Kerberos, PKI, OpenID Connect, or token +authentication providers, a dedicated {kib} instance is *required*. The security +tokens that are used in these contexts are cluster-specific, therefore you +cannot use a single {kib} instance to connect to both production and monitoring +clusters. For more information about the recommended configuration, see +{ref}/monitoring-overview.html[Monitoring overview]. + . Identify where to retrieve monitoring data from. + -- -The cluster that contains the monitoring data is referred to -as the _monitoring cluster_. - -TIP: If the monitoring data is stored on a *dedicated* monitoring cluster, it is +If the monitoring data is stored on a dedicated monitoring cluster, it is accessible even when the cluster you're monitoring is not. If you have at least a gold license, you can send data from multiple clusters to the same monitoring cluster and view them all through the same instance of {kib}. From 311805a57d8109832648db6e3b7fe4143e18e030 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Tue, 22 Sep 2020 12:50:44 -0400 Subject: [PATCH 09/15] [Ingest Manager] Adding bulk packages upgrade api (#77827) * Adding bulk upgrade api * Addressing comments * Removing todo * Changing body field * Adding helper for getting the bulk install route * Adding request spec * Pulling in Johns changes * Removing test for same package upgraded multiple times * Pulling in John's error handling changes * Fixing type error --- .../ingest_manager/common/constants/routes.ts | 2 + .../ingest_manager/common/services/routes.ts | 4 + .../common/types/rest_spec/epm.ts | 24 +++ .../ingest_manager/server/errors/handlers.ts | 29 +-- .../ingest_manager/server/errors/index.ts | 2 +- .../server/routes/epm/handlers.ts | 62 +++--- .../ingest_manager/server/routes/epm/index.ts | 11 + .../server/services/epm/packages/install.ts | 188 +++++++++++++++++- .../server/types/rest_spec/epm.ts | 6 + .../apis/epm/bulk_upgrade.ts | 113 +++++++++++ .../apis/epm/index.js | 1 + 11 files changed, 394 insertions(+), 48 deletions(-) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/epm/bulk_upgrade.ts diff --git a/x-pack/plugins/ingest_manager/common/constants/routes.ts b/x-pack/plugins/ingest_manager/common/constants/routes.ts index 3e065142ea1013..378a6c6c121596 100644 --- a/x-pack/plugins/ingest_manager/common/constants/routes.ts +++ b/x-pack/plugins/ingest_manager/common/constants/routes.ts @@ -15,9 +15,11 @@ export const LIMITED_CONCURRENCY_ROUTE_TAG = 'ingest:limited-concurrency'; // EPM API routes const EPM_PACKAGES_MANY = `${EPM_API_ROOT}/packages`; +const EPM_PACKAGES_BULK = `${EPM_PACKAGES_MANY}/_bulk`; const EPM_PACKAGES_ONE = `${EPM_PACKAGES_MANY}/{pkgkey}`; const EPM_PACKAGES_FILE = `${EPM_PACKAGES_MANY}/{pkgName}/{pkgVersion}`; export const EPM_API_ROUTES = { + BULK_INSTALL_PATTERN: EPM_PACKAGES_BULK, LIST_PATTERN: EPM_PACKAGES_MANY, LIMITED_LIST_PATTERN: `${EPM_PACKAGES_MANY}/limited`, INFO_PATTERN: EPM_PACKAGES_ONE, diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index b7521f95b4f836..ec7c0ee8508343 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -46,6 +46,10 @@ export const epmRouteService = { ); // trim trailing slash }, + getBulkInstallPath: () => { + return EPM_API_ROUTES.BULK_INSTALL_PATTERN; + }, + getRemovePath: (pkgkey: string) => { return EPM_API_ROUTES.DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash }, diff --git a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts index 54e767fee4b224..7ed2fed91aa93c 100644 --- a/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/rest_spec/epm.ts @@ -71,6 +71,30 @@ export interface InstallPackageResponse { response: AssetReference[]; } +export interface IBulkInstallPackageError { + name: string; + statusCode: number; + error: string | Error; +} + +export interface BulkInstallPackageInfo { + name: string; + newVersion: string; + // this will be null if no package was present before the upgrade (aka it was an install) + oldVersion: string | null; + assets: AssetReference[]; +} + +export interface BulkInstallPackagesResponse { + response: Array; +} + +export interface BulkInstallPackagesRequest { + body: { + packages: string[]; + }; +} + export interface MessageResponse { response: string; } diff --git a/x-pack/plugins/ingest_manager/server/errors/handlers.ts b/x-pack/plugins/ingest_manager/server/errors/handlers.ts index 9f776565cf2626..b621f2dd293315 100644 --- a/x-pack/plugins/ingest_manager/server/errors/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/errors/handlers.ts @@ -56,10 +56,7 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { return 400; // Bad Request }; -export const defaultIngestErrorHandler: IngestErrorHandler = async ({ - error, - response, -}: IngestErrorHandlerParams): Promise => { +export function ingestErrorToResponseOptions(error: IngestErrorHandlerParams['error']) { const logger = appContextService.getLogger(); if (isLegacyESClientError(error)) { // there was a problem communicating with ES (e.g. via `callCluster`) @@ -72,36 +69,44 @@ export const defaultIngestErrorHandler: IngestErrorHandler = async ({ logger.error(message); - return response.customError({ + return { statusCode: error?.statusCode || error.status, body: { message }, - }); + }; } // our "expected" errors if (error instanceof IngestManagerError) { // only log the message logger.error(error.message); - return response.customError({ + return { statusCode: getHTTPResponseCode(error), body: { message: error.message }, - }); + }; } // handle any older Boom-based errors or the few places our app uses them if (isBoom(error)) { // only log the message logger.error(error.output.payload.message); - return response.customError({ + return { statusCode: error.output.statusCode, body: { message: error.output.payload.message }, - }); + }; } // not sure what type of error this is. log as much as possible logger.error(error); - return response.customError({ + return { statusCode: 500, body: { message: error.message }, - }); + }; +} + +export const defaultIngestErrorHandler: IngestErrorHandler = async ({ + error, + response, +}: IngestErrorHandlerParams): Promise => { + const options = ingestErrorToResponseOptions(error); + return response.customError(options); }; diff --git a/x-pack/plugins/ingest_manager/server/errors/index.ts b/x-pack/plugins/ingest_manager/server/errors/index.ts index 5e36a2ec9a884a..f495bf551dcff7 100644 --- a/x-pack/plugins/ingest_manager/server/errors/index.ts +++ b/x-pack/plugins/ingest_manager/server/errors/index.ts @@ -5,7 +5,7 @@ */ /* eslint-disable max-classes-per-file */ -export { defaultIngestErrorHandler } from './handlers'; +export { defaultIngestErrorHandler, ingestErrorToResponseOptions } from './handlers'; export class IngestManagerError extends Error { constructor(message?: string) { diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index c40e0e4ac5c0b8..7ae896c1f30a6a 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -5,7 +5,6 @@ */ import { TypeOf } from '@kbn/config-schema'; import { RequestHandler, CustomHttpResponseOptions } from 'src/core/server'; -import { appContextService } from '../../services'; import { GetInfoResponse, InstallPackageResponse, @@ -14,6 +13,7 @@ import { GetCategoriesResponse, GetPackagesResponse, GetLimitedPackagesResponse, + BulkInstallPackagesResponse, } from '../../../common'; import { GetCategoriesRequestSchema, @@ -23,6 +23,7 @@ import { InstallPackageFromRegistryRequestSchema, InstallPackageByUploadRequestSchema, DeletePackageRequestSchema, + BulkUpgradePackagesFromRegistryRequestSchema, } from '../../types'; import { getCategories, @@ -34,9 +35,12 @@ import { getLimitedPackages, getInstallationObject, } from '../../services/epm/packages'; -import { IngestManagerError, defaultIngestErrorHandler } from '../../errors'; +import { defaultIngestErrorHandler } from '../../errors'; import { splitPkgKey } from '../../services/epm/registry'; -import { getInstallType } from '../../services/epm/packages/install'; +import { + handleInstallPackageFailure, + bulkInstallPackages, +} from '../../services/epm/packages/install'; export const getCategoriesHandler: RequestHandler< undefined, @@ -136,13 +140,11 @@ export const installPackageFromRegistryHandler: RequestHandler< undefined, TypeOf > = async (context, request, response) => { - const logger = appContextService.getLogger(); const savedObjectsClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; const { pkgkey } = request.params; const { pkgName, pkgVersion } = splitPkgKey(pkgkey); const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); - const installType = getInstallType({ pkgVersion, installedPkg }); try { const res = await installPackage({ savedObjectsClient, @@ -155,36 +157,38 @@ export const installPackageFromRegistryHandler: RequestHandler< }; return response.ok({ body }); } catch (e) { - // could have also done `return defaultIngestErrorHandler({ error: e, response })` at each of the returns, - // but doing it this way will log the outer/install errors before any inner/rollback errors const defaultResult = await defaultIngestErrorHandler({ error: e, response }); - if (e instanceof IngestManagerError) { - return defaultResult; - } + await handleInstallPackageFailure({ + savedObjectsClient, + error: e, + pkgName, + pkgVersion, + installedPkg, + callCluster, + }); - // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update - try { - if (installType === 'install' || installType === 'reinstall') { - logger.error(`uninstalling ${pkgkey} after error installing`); - await removeInstallation({ savedObjectsClient, pkgkey, callCluster }); - } - if (installType === 'update') { - // @ts-ignore getInstallType ensures we have installedPkg - const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; - logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); - await installPackage({ - savedObjectsClient, - pkgkey: prevVersion, - callCluster, - }); - } - } catch (error) { - logger.error(`failed to uninstall or rollback package after installation error ${error}`); - } return defaultResult; } }; +export const bulkInstallPackagesFromRegistryHandler: RequestHandler< + undefined, + undefined, + TypeOf +> = async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; + const res = await bulkInstallPackages({ + savedObjectsClient, + callCluster, + packagesToUpgrade: request.body.packages, + }); + const body: BulkInstallPackagesResponse = { + response: res, + }; + return response.ok({ body }); +}; + export const installPackageByUploadHandler: RequestHandler< undefined, undefined, diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts index 9048652f0e8a9c..eaf61335b5e069 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/index.ts @@ -14,6 +14,7 @@ import { installPackageFromRegistryHandler, installPackageByUploadHandler, deletePackageHandler, + bulkInstallPackagesFromRegistryHandler, } from './handlers'; import { GetCategoriesRequestSchema, @@ -23,6 +24,7 @@ import { InstallPackageFromRegistryRequestSchema, InstallPackageByUploadRequestSchema, DeletePackageRequestSchema, + BulkUpgradePackagesFromRegistryRequestSchema, } from '../../types'; const MAX_FILE_SIZE_BYTES = 104857600; // 100MB @@ -82,6 +84,15 @@ export const registerRoutes = (router: IRouter) => { installPackageFromRegistryHandler ); + router.post( + { + path: EPM_API_ROUTES.BULK_INSTALL_PATTERN, + validate: BulkUpgradePackagesFromRegistryRequestSchema, + options: { tags: [`access:${PLUGIN_ID}-all`] }, + }, + bulkInstallPackagesFromRegistryHandler + ); + router.post( { path: EPM_API_ROUTES.INSTALL_BY_UPLOAD_PATTERN, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 54b9c4d3fbb172..800151a41a4299 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -6,6 +6,9 @@ import { SavedObject, SavedObjectsClientContract } from 'src/core/server'; import semver from 'semver'; +import Boom from 'boom'; +import { UnwrapPromise } from '@kbn/utility-types'; +import { BulkInstallPackageInfo, IBulkInstallPackageError } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants'; import { AssetReference, @@ -32,10 +35,15 @@ import { ArchiveAsset, } from '../kibana/assets/install'; import { updateCurrentWriteIndices } from '../elasticsearch/template/template'; -import { deleteKibanaSavedObjectsAssets } from './remove'; -import { PackageOutdatedError } from '../../../errors'; +import { deleteKibanaSavedObjectsAssets, removeInstallation } from './remove'; +import { + IngestManagerError, + PackageOutdatedError, + ingestErrorToResponseOptions, +} from '../../../errors'; import { getPackageSavedObjects } from './get'; import { installTransformForDataset } from '../elasticsearch/transform/install'; +import { appContextService } from '../../app_context'; export async function installLatestPackage(options: { savedObjectsClient: SavedObjectsClientContract; @@ -94,17 +102,185 @@ export async function ensureInstalledPackage(options: { return installation; } -export async function installPackage({ +export async function handleInstallPackageFailure({ savedObjectsClient, - pkgkey, + error, + pkgName, + pkgVersion, + installedPkg, callCluster, - force = false, }: { + savedObjectsClient: SavedObjectsClientContract; + error: IngestManagerError | Boom | Error; + pkgName: string; + pkgVersion: string; + installedPkg: SavedObject | undefined; + callCluster: CallESAsCurrentUser; +}) { + if (error instanceof IngestManagerError) { + return; + } + const logger = appContextService.getLogger(); + const pkgkey = Registry.pkgToPkgKey({ + name: pkgName, + version: pkgVersion, + }); + + // if there is an unknown server error, uninstall any package assets or reinstall the previous version if update + try { + const installType = getInstallType({ pkgVersion, installedPkg }); + if (installType === 'install' || installType === 'reinstall') { + logger.error(`uninstalling ${pkgkey} after error installing`); + await removeInstallation({ savedObjectsClient, pkgkey, callCluster }); + } + + if (installType === 'update') { + if (!installedPkg) { + logger.error( + `failed to rollback package after installation error ${error} because saved object was undefined` + ); + return; + } + const prevVersion = `${pkgName}-${installedPkg.attributes.version}`; + logger.error(`rolling back to ${prevVersion} after error installing ${pkgkey}`); + await installPackage({ + savedObjectsClient, + pkgkey: prevVersion, + callCluster, + }); + } + } catch (e) { + logger.error(`failed to uninstall or rollback package after installation error ${e}`); + } +} + +type BulkInstallResponse = BulkInstallPackageInfo | IBulkInstallPackageError; +function bulkInstallErrorToOptions({ + pkgToUpgrade, + error, +}: { + pkgToUpgrade: string; + error: Error; +}): IBulkInstallPackageError { + const { statusCode, body } = ingestErrorToResponseOptions(error); + return { + name: pkgToUpgrade, + statusCode, + error: body.message, + }; +} + +interface UpgradePackageParams { + savedObjectsClient: SavedObjectsClientContract; + callCluster: CallESAsCurrentUser; + installedPkg: UnwrapPromise>; + latestPkg: UnwrapPromise>; + pkgToUpgrade: string; +} +async function upgradePackage({ + savedObjectsClient, + callCluster, + installedPkg, + latestPkg, + pkgToUpgrade, +}: UpgradePackageParams): Promise { + if (!installedPkg || semver.gt(latestPkg.version, installedPkg.attributes.version)) { + const pkgkey = Registry.pkgToPkgKey({ + name: latestPkg.name, + version: latestPkg.version, + }); + + try { + const assets = await installPackage({ savedObjectsClient, pkgkey, callCluster }); + return { + name: pkgToUpgrade, + newVersion: latestPkg.version, + oldVersion: installedPkg?.attributes.version ?? null, + assets, + }; + } catch (installFailed) { + await handleInstallPackageFailure({ + savedObjectsClient, + error: installFailed, + pkgName: latestPkg.name, + pkgVersion: latestPkg.version, + installedPkg, + callCluster, + }); + return bulkInstallErrorToOptions({ pkgToUpgrade, error: installFailed }); + } + } else { + // package was already at the latest version + return { + name: pkgToUpgrade, + newVersion: latestPkg.version, + oldVersion: latestPkg.version, + assets: [ + ...installedPkg.attributes.installed_es, + ...installedPkg.attributes.installed_kibana, + ], + }; + } +} + +interface BulkInstallPackagesParams { + savedObjectsClient: SavedObjectsClientContract; + packagesToUpgrade: string[]; + callCluster: CallESAsCurrentUser; +} +export async function bulkInstallPackages({ + savedObjectsClient, + packagesToUpgrade, + callCluster, +}: BulkInstallPackagesParams): Promise { + const installedAndLatestPromises = packagesToUpgrade.map((pkgToUpgrade) => + Promise.all([ + getInstallationObject({ savedObjectsClient, pkgName: pkgToUpgrade }), + Registry.fetchFindLatestPackage(pkgToUpgrade), + ]) + ); + const installedAndLatestResults = await Promise.allSettled(installedAndLatestPromises); + const installResponsePromises = installedAndLatestResults.map(async (result, index) => { + const pkgToUpgrade = packagesToUpgrade[index]; + if (result.status === 'fulfilled') { + const [installedPkg, latestPkg] = result.value; + return upgradePackage({ + savedObjectsClient, + callCluster, + installedPkg, + latestPkg, + pkgToUpgrade, + }); + } else { + return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason }); + } + }); + const installResults = await Promise.allSettled(installResponsePromises); + const installResponses = installResults.map((result, index) => { + const pkgToUpgrade = packagesToUpgrade[index]; + if (result.status === 'fulfilled') { + return result.value; + } else { + return bulkInstallErrorToOptions({ pkgToUpgrade, error: result.reason }); + } + }); + + return installResponses; +} + +interface InstallPackageParams { savedObjectsClient: SavedObjectsClientContract; pkgkey: string; callCluster: CallESAsCurrentUser; force?: boolean; -}): Promise { +} + +export async function installPackage({ + savedObjectsClient, + pkgkey, + callCluster, + force = false, +}: InstallPackageParams): Promise { // TODO: change epm API to /packageName/version so we don't need to do this const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey); // TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts index d7a801feec34f5..5d2a078374854d 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/epm.ts @@ -43,6 +43,12 @@ export const InstallPackageFromRegistryRequestSchema = { ), }; +export const BulkUpgradePackagesFromRegistryRequestSchema = { + body: schema.object({ + packages: schema.arrayOf(schema.string(), { minSize: 1 }), + }), +}; + export const InstallPackageByUploadRequestSchema = { body: schema.buffer(), }; diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/bulk_upgrade.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/bulk_upgrade.ts new file mode 100644 index 00000000000000..e377ea5a762f95 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/bulk_upgrade.ts @@ -0,0 +1,113 @@ +/* + * 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'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; +import { + BulkInstallPackageInfo, + BulkInstallPackagesResponse, + IBulkInstallPackageError, +} from '../../../../plugins/ingest_manager/common'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const supertest = getService('supertest'); + + const deletePackage = async (pkgkey: string) => { + await supertest.delete(`/api/ingest_manager/epm/packages/${pkgkey}`).set('kbn-xsrf', 'xxxx'); + }; + + describe('bulk package upgrade api', async () => { + skipIfNoDockerRegistry(providerContext); + + describe('bulk package upgrade with a package already installed', async () => { + beforeEach(async () => { + await supertest + .post(`/api/ingest_manager/epm/packages/multiple_versions-0.1.0`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }) + .expect(200); + }); + afterEach(async () => { + await deletePackage('multiple_versions-0.1.0'); + await deletePackage('multiple_versions-0.3.0'); + await deletePackage('overrides-0.1.0'); + }); + + it('should return 400 if no packages are requested for upgrade', async function () { + await supertest + .post(`/api/ingest_manager/epm/packages/_bulk`) + .set('kbn-xsrf', 'xxxx') + .expect(400); + }); + it('should return 200 and an array for upgrading a package', async function () { + const { body }: { body: BulkInstallPackagesResponse } = await supertest + .post(`/api/ingest_manager/epm/packages/_bulk`) + .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions'] }) + .expect(200); + expect(body.response.length).equal(1); + expect(body.response[0].name).equal('multiple_versions'); + const entry = body.response[0] as BulkInstallPackageInfo; + expect(entry.oldVersion).equal('0.1.0'); + expect(entry.newVersion).equal('0.3.0'); + }); + it('should return an error for packages that do not exist', async function () { + const { body }: { body: BulkInstallPackagesResponse } = await supertest + .post(`/api/ingest_manager/epm/packages/_bulk`) + .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions', 'blahblah'] }) + .expect(200); + expect(body.response.length).equal(2); + expect(body.response[0].name).equal('multiple_versions'); + const entry = body.response[0] as BulkInstallPackageInfo; + expect(entry.oldVersion).equal('0.1.0'); + expect(entry.newVersion).equal('0.3.0'); + + const err = body.response[1] as IBulkInstallPackageError; + expect(err.statusCode).equal(404); + expect(body.response[1].name).equal('blahblah'); + }); + it('should upgrade multiple packages', async function () { + const { body }: { body: BulkInstallPackagesResponse } = await supertest + .post(`/api/ingest_manager/epm/packages/_bulk`) + .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions', 'overrides'] }) + .expect(200); + expect(body.response.length).equal(2); + expect(body.response[0].name).equal('multiple_versions'); + let entry = body.response[0] as BulkInstallPackageInfo; + expect(entry.oldVersion).equal('0.1.0'); + expect(entry.newVersion).equal('0.3.0'); + + entry = body.response[1] as BulkInstallPackageInfo; + expect(entry.oldVersion).equal(null); + expect(entry.newVersion).equal('0.1.0'); + expect(entry.name).equal('overrides'); + }); + }); + + describe('bulk upgrade without package already installed', async () => { + afterEach(async () => { + await deletePackage('multiple_versions-0.3.0'); + }); + + it('should return 200 and an array for upgrading a package', async function () { + const { body }: { body: BulkInstallPackagesResponse } = await supertest + .post(`/api/ingest_manager/epm/packages/_bulk`) + .set('kbn-xsrf', 'xxxx') + .send({ packages: ['multiple_versions'] }) + .expect(200); + expect(body.response.length).equal(1); + expect(body.response[0].name).equal('multiple_versions'); + const entry = body.response[0] as BulkInstallPackageInfo; + expect(entry.oldVersion).equal(null); + expect(entry.newVersion).equal('0.3.0'); + }); + }); + }); +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/index.js b/x-pack/test/ingest_manager_api_integration/apis/epm/index.js index 28743ee5f43c2d..e509babc9828b6 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/index.js @@ -16,6 +16,7 @@ export default function loadTests({ loadTestFile }) { loadTestFile(require.resolve('./install_prerelease')); loadTestFile(require.resolve('./install_remove_assets')); loadTestFile(require.resolve('./install_update')); + loadTestFile(require.resolve('./bulk_upgrade')); loadTestFile(require.resolve('./update_assets')); loadTestFile(require.resolve('./data_stream')); loadTestFile(require.resolve('./package_install_complete')); From e0aeebc149b4ba788364f87a28052c6b0858aeb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Tue, 22 Sep 2020 19:32:50 +0200 Subject: [PATCH 10/15] [Logs UI] Correctly filter for log rate anomaly examples with missing dataset (#76775) This fixes #76493 by querying for the "unknown" (i.e. empty) dataset using an exists clause. This should be in line with how ML anomaly detection treats missing partition values. --- .../log_analysis/queries/log_entry_examples.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index eac5fa84d85a7f..1b6a4c611e177c 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -33,7 +33,7 @@ export const createLogEntryExamplesQuery = ( }, }, }, - ...(!!dataset + ...(dataset !== '' ? [ { term: { @@ -41,7 +41,19 @@ export const createLogEntryExamplesQuery = ( }, }, ] - : []), + : [ + { + bool: { + must_not: [ + { + exists: { + field: partitionField, + }, + }, + ], + }, + }, + ]), ...(categoryQuery ? [ { From b7cc6d3f2f861303911a9d2913c285d8d883f359 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 22 Sep 2020 10:34:25 -0700 Subject: [PATCH 11/15] [Reporting/PDF] Switch layout to no border (#78036) --- .../server/export_types/printable_pdf/lib/pdf/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js index 1042fd66abad7f..8840fd524f3e43 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.js @@ -104,7 +104,7 @@ class PdfMaker { table: { body: [[img]], }, - layout: 'simpleBorder', + layout: 'noBorder', }; contents.push(wrappedImg); From f412971d5abd6ed7e885f62d82d1e115ac86e574 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 22 Sep 2020 10:50:39 -0700 Subject: [PATCH 12/15] skip flaky suite (#77969) --- x-pack/test/functional/apps/lens/smokescreen.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/lens/smokescreen.ts b/x-pack/test/functional/apps/lens/smokescreen.ts index 42807a23cb13ab..05047fab2517d6 100644 --- a/x-pack/test/functional/apps/lens/smokescreen.ts +++ b/x-pack/test/functional/apps/lens/smokescreen.ts @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const testSubjects = getService('testSubjects'); - describe('lens smokescreen tests', () => { + // Failing: See https://github.com/elastic/kibana/issues/77969 + describe.skip('lens smokescreen tests', () => { it('should allow creation of lens xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From 426df45c6f34e0b7acbe985b5224e2522a9d2074 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Tue, 22 Sep 2020 13:43:35 -0500 Subject: [PATCH 13/15] [ML] Edit text for deleting calendars & add functional tests for Calendars and Filter Lists (#77566) Co-authored-by: Elastic Machine --- .../__snapshots__/calendar_form.test.js.snap | 2 + .../edit/calendar_form/calendar_form.js | 4 +- .../edit/new_event_modal/new_event_modal.js | 17 +- .../settings/calendars/list/calendars_list.js | 22 +- .../add_item_popover.test.js.snap | 12 +- .../add_item_popover/add_item_popover.js | 9 +- .../delete_filter_list_modal.test.js.snap | 1 + .../delete_filter_list_modal.js | 1 + .../edit_description_popover.test.js.snap | 3 + .../edit_description_popover.js | 1 + .../edit_filter_list.test.js.snap | 16 ++ .../edit/__snapshots__/header.test.js.snap | 10 +- .../filter_lists/edit/edit_filter_list.js | 6 +- .../settings/filter_lists/edit/header.js | 3 +- .../settings/filter_lists/list/table.js | 2 +- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - x-pack/test/functional/apps/ml/index.ts | 1 + .../apps/ml/permissions/full_ml_access.ts | 2 +- .../apps/ml/settings/calendar_creation.ts | 100 +++++++++ .../apps/ml/settings/calendar_delete.ts | 62 ++++++ .../apps/ml/settings/calendar_edit.ts | 101 +++++++++ .../functional/apps/ml/settings/common.ts | 26 +++ .../apps/ml/settings/filter_list_creation.ts | 49 +++++ .../apps/ml/settings/filter_list_delete.ts | 68 ++++++ .../apps/ml/settings/filter_list_edit.ts | 78 +++++++ .../test/functional/apps/ml/settings/index.ts | 20 ++ x-pack/test/functional/services/ml/index.ts | 4 +- .../functional/services/ml/security_ui.ts | 1 - .../services/ml/settings_calendar.ts | 203 +++++++++++++++++- .../services/ml/settings_filter_list.ts | 137 +++++++++++- 31 files changed, 924 insertions(+), 41 deletions(-) create mode 100644 x-pack/test/functional/apps/ml/settings/calendar_creation.ts create mode 100644 x-pack/test/functional/apps/ml/settings/calendar_delete.ts create mode 100644 x-pack/test/functional/apps/ml/settings/calendar_edit.ts create mode 100644 x-pack/test/functional/apps/ml/settings/common.ts create mode 100644 x-pack/test/functional/apps/ml/settings/filter_list_creation.ts create mode 100644 x-pack/test/functional/apps/ml/settings/filter_list_delete.ts create mode 100644 x-pack/test/functional/apps/ml/settings/filter_list_edit.ts create mode 100644 x-pack/test/functional/apps/ml/settings/index.ts diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap index fed435d47dfc6b..ad76bb91156172 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/calendar_form/__snapshots__/calendar_form.test.js.snap @@ -56,6 +56,7 @@ exports[`CalendarForm Renders calendar form 1`] = ` labelType="label" > - +

{description}

@@ -116,6 +116,7 @@ export const CalendarForm = ({ value={calendarId} onChange={onCalendarIdChange} disabled={isEdit === true || saving === true} + data-test-subj="mlCalendarIdInput" /> @@ -132,6 +133,7 @@ export const CalendarForm = ({ value={description} onChange={onDescriptionChange} disabled={isEdit === true || saving === true} + data-test-subj="mlCalendarDescriptionInput" /> diff --git a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js index d80e248674a8f6..0b5d2b7b5a3ea6 100644 --- a/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js +++ b/x-pack/plugins/ml/public/application/settings/calendars/edit/new_event_modal/new_event_modal.js @@ -257,7 +257,12 @@ export class NewEventModal extends Component { return ( - + @@ -293,13 +299,18 @@ export class NewEventModal extends Component { - + - + c.calendar_id).join(', '), + }} /> } onCancel={this.closeDestroyModal} @@ -130,18 +135,7 @@ export class CalendarsListUI extends Component { } buttonColor="danger" defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} - > -

- c.calendar_id).join(', '), - }} - /> -

-
+ /> ); } diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap index 6e9cd17deabee8..969406724537d2 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/__snapshots__/add_item_popover.test.js.snap @@ -7,7 +7,7 @@ exports[`AddItemPopover calls addItems with multiple items on clicking Add butto button={ @@ -71,6 +72,7 @@ exports[`AddItemPopover calls addItems with multiple items on clicking Add butto grow={false} > @@ -93,7 +95,7 @@ exports[`AddItemPopover opens the popover onButtonClick 1`] = ` button={ @@ -157,6 +160,7 @@ exports[`AddItemPopover opens the popover onButtonClick 1`] = ` grow={false} > @@ -179,7 +183,7 @@ exports[`AddItemPopover renders the popover 1`] = ` button={ @@ -243,6 +248,7 @@ exports[`AddItemPopover renders the popover 1`] = ` grow={false} > diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js index 07e060d87b36a4..53a3877e2f1bdb 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/add_item_popover/add_item_popover.js @@ -84,7 +84,7 @@ export class AddItemPopover extends Component { iconSide="right" onClick={this.onButtonClick} isDisabled={this.props.canCreateFilter === false} - data-test-subj="mlFilterListAddItemButton" + data-test-subj="mlFilterListOpenNewItemsPopoverButton" > } > - + @@ -127,6 +131,7 @@ export class AddItemPopover extends Component { } + data-test-subj="mlFilterListDeleteConfirmation" defaultFocusedButton="confirm" onCancel={[Function]} onConfirm={[Function]} diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js index 75fdce8e2bac80..5aafe79645f6a2 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/delete_filter_list_modal/delete_filter_list_modal.js @@ -86,6 +86,7 @@ export class DeleteFilterListModal extends Component { } buttonColor="danger" defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} + data-test-subj={'mlFilterListDeleteConfirmation'} /> ); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap index 9904e90a5afae9..268b93923a4325 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/__snapshots__/edit_description_popover.test.js.snap @@ -47,6 +47,7 @@ exports[`FilterListUsagePopover opens the popover onButtonClick 1`] = ` labelType="label" > diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js index 06ace034ca8193..b7bcb201f2438b 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/components/edit_description_popover/edit_description_popover.js @@ -102,6 +102,7 @@ export class EditDescriptionPopover extends Component { name="filter_list_description" value={value} onChange={this.onChange} + data-test-subj={'mlFilterListDescriptionInput'} /> diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap index c2fab64473228c..f6a4f769755534 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/__snapshots__/edit_filter_list.test.js.snap @@ -80,6 +80,7 @@ exports[`EditFilterList adds new items to filter list 1`] = ` grow={false} > - +

A test filter list

@@ -180,6 +183,7 @@ exports[`EditFilterListHeader renders the header when creating a new filter list labelType="label" > - +

A test filter list

diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js index 681c54ca9eee07..9ea470a388f024 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/edit/edit_filter_list.js @@ -362,7 +362,10 @@ export class EditFilterListUI extends Component { /> - this.returnToFiltersList()}> + this.returnToFiltersList()} + > updateNewFilterId(e.target.value)} + data-test-subj={'mlNewFilterListIdInput'} /> ); @@ -96,7 +97,7 @@ export const EditFilterListHeader = ({ if (description !== undefined && description.length > 0) { descriptionField = ( - +

{description}

); diff --git a/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js b/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js index ed992b4e866ffa..9e1457483cb2c4 100644 --- a/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js +++ b/x-pack/plugins/ml/public/application/settings/filter_lists/list/table.js @@ -214,7 +214,7 @@ export function FilterListsTable({ isSelectable={true} data-test-subj="mlFilterListsTable" rowProps={(item) => ({ - 'data-test-subj': `mlFilterListsRow row-${item.filter_id}`, + 'data-test-subj': `mlFilterListRow row-${item.filter_id}`, })} /> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f626835da8e11e..7ca4e02068d418 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10527,8 +10527,6 @@ "xpack.ml.calendarsList.deleteCalendars.deletingCalendarSuccessNotificationMessage": "{messageId} が選択されました", "xpack.ml.calendarsList.deleteCalendarsModal.cancelButtonLabel": "キャンセル", "xpack.ml.calendarsList.deleteCalendarsModal.deleteButtonLabel": "削除", - "xpack.ml.calendarsList.deleteCalendarsModal.deleteCalendarsDescription": "{calendarsCount, plural, one {このカレンダー} other {これらのカレンダー}}を削除しますか?{calendarsList}", - "xpack.ml.calendarsList.deleteCalendarsModal.deleteCalendarTitle": "カレンダーの削除", "xpack.ml.calendarsList.errorWithLoadingListOfCalendarsErrorMessage": "カレンダーのリストの読み込み中にエラーが発生しました。", "xpack.ml.calendarsList.table.allJobsLabel": "すべてのジョブに適用", "xpack.ml.calendarsList.table.deleteButtonLabel": "削除", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d6baa87ca9e2f0..2e1fb55777cdf3 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10533,8 +10533,6 @@ "xpack.ml.calendarsList.deleteCalendars.deletingCalendarSuccessNotificationMessage": "已删除 {messageId}", "xpack.ml.calendarsList.deleteCalendarsModal.cancelButtonLabel": "取消", "xpack.ml.calendarsList.deleteCalendarsModal.deleteButtonLabel": "删除", - "xpack.ml.calendarsList.deleteCalendarsModal.deleteCalendarsDescription": "是否删除{calendarsCount, plural, one {此日历} other {这些日历}}?{calendarsList}", - "xpack.ml.calendarsList.deleteCalendarsModal.deleteCalendarTitle": "删除日历", "xpack.ml.calendarsList.errorWithLoadingListOfCalendarsErrorMessage": "加载日历列表时出错。", "xpack.ml.calendarsList.table.allJobsLabel": "应用到所有作业", "xpack.ml.calendarsList.table.deleteButtonLabel": "删除", diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index e224f5c8bb1285..74dc0fc3ca9f0f 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -46,5 +46,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./anomaly_detection')); loadTestFile(require.resolve('./data_visualizer')); loadTestFile(require.resolve('./data_frame_analytics')); + loadTestFile(require.resolve('./settings')); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index eed7489b09fe63..c3dde872fa4a67 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -438,7 +438,7 @@ export default function ({ getService }: FtrProviderContext) { 'should display enabled elements of the edit calendar page' ); await ml.settingsFilterList.assertEditDescriptionButtonEnabled(true); - await ml.settingsFilterList.assertAddItemButtonEnabled(true); + await ml.settingsFilterList.assertAddItemsButtonEnabled(true); await ml.testExecution.logTestStep('should display the filter item in the list'); await ml.settingsFilterList.assertFilterItemExists(filterItems[0]); diff --git a/x-pack/test/functional/apps/ml/settings/calendar_creation.ts b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts new file mode 100644 index 00000000000000..5b1e3b0a12b134 --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/calendar_creation.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { asyncForEach, createJobConfig } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + const esArchiver = getService('esArchiver'); + + const calendarId = 'test_calendar_id'; + const jobConfigs = [createJobConfig('test_calendar_ad_1'), createJobConfig('test_calendar_ad_2')]; + + describe('calendar creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + + await asyncForEach(jobConfigs, async (jobConfig) => { + await ml.api.createAnomalyDetectionJob(jobConfig); + }); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('creates new calendar that applies to all jobs', async () => { + await ml.testExecution.logTestStep('calendar creation loads the calendar management page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToCalendarManagement(); + + await ml.testExecution.logTestStep('calendar creation loads the new calendar edit page'); + await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); + await ml.settingsCalendar.navigateToCalendarCreationPage(); + + await ml.testExecution.logTestStep('calendar creation sets calendar to apply to all jobs'); + await ml.settingsCalendar.toggleApplyToAllJobsSwitch(true); + await ml.settingsCalendar.assertJobSelectionNotExists(); + await ml.settingsCalendar.assertJobGroupSelectionNotExists(); + + await ml.testExecution.logTestStep('calendar creation sets the calendar id and description'); + await ml.settingsCalendar.setCalendarId(calendarId); + await ml.settingsCalendar.setCalendarDescription('test calendar description'); + + await ml.testExecution.logTestStep('calendar creation creates new calendar event'); + await ml.settingsCalendar.openNewCalendarEventForm(); + await ml.settingsCalendar.setCalendarEventDescription('holiday'); + await ml.settingsCalendar.addNewCalendarEvent(); + await ml.settingsCalendar.assertEventRowExists('holiday'); + + await ml.testExecution.logTestStep( + 'calendar creation saves the new calendar and displays it in the list of calendars ' + ); + await ml.settingsCalendar.saveCalendar(); + + await ml.settingsCalendar.assertCalendarRowExists(calendarId); + }); + + it('creates new calendar that applies to specific jobs', async () => { + await ml.testExecution.logTestStep('calendar creation loads the calendar management page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToCalendarManagement(); + + await ml.testExecution.logTestStep('calendar creation loads the new calendar edit page'); + await ml.settingsCalendar.assertCreateCalendarButtonEnabled(true); + await ml.settingsCalendar.navigateToCalendarCreationPage(); + + await ml.testExecution.logTestStep( + 'calendar creation verifies the job selection and job group section are displayed' + ); + await ml.settingsCalendar.assertJobSelectionExists(); + await ml.settingsCalendar.assertJobSelectionEnabled(true); + await ml.settingsCalendar.assertJobGroupSelectionExists(); + await ml.settingsCalendar.assertJobGroupSelectionEnabled(true); + + await ml.testExecution.logTestStep('calendar creation sets the calendar id'); + await ml.settingsCalendar.setCalendarId(calendarId); + + await ml.testExecution.logTestStep('calendar creation sets the job selection'); + await asyncForEach(jobConfigs, async (jobConfig) => { + await ml.settingsCalendar.selectJob(jobConfig.job_id); + }); + + await ml.settingsCalendar.saveCalendar(); + await ml.settingsCalendar.assertCalendarRowExists(calendarId); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/calendar_delete.ts b/x-pack/test/functional/apps/ml/settings/calendar_delete.ts new file mode 100644 index 00000000000000..2cc4f91d5528f6 --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/calendar_delete.ts @@ -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 { FtrProviderContext } from '../../../ftr_provider_context'; +import { asyncForEach } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + + const testDataList = [1, 2].map((n) => ({ + calendarId: `test_delete_calendar_${n}`, + description: `test description ${n}`, + })); + + describe('calendar delete', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + + await asyncForEach(testDataList, async ({ calendarId, description }) => { + await ml.api.createCalendar(calendarId, { + description, + }); + }); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + + // clean up created calendars + await asyncForEach(testDataList, async ({ calendarId }) => { + await ml.api.deleteCalendar(calendarId); + }); + }); + + it('deletes multiple calendars', async () => { + await ml.testExecution.logTestStep('calendar delete loads the calendar list management page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToCalendarManagement(); + + await ml.testExecution.logTestStep('calendar delete selects multiple calendars for deletion'); + await asyncForEach(testDataList, async ({ calendarId }) => { + await ml.settingsCalendar.assertCalendarRowExists(calendarId); + await ml.settingsCalendar.selectCalendarRow(calendarId); + }); + + await ml.testExecution.logTestStep('calendar delete clicks the delete button'); + await ml.settingsCalendar.deleteCalendar(); + + await ml.testExecution.logTestStep( + 'calendar delete validates the calendars are deleted from the table' + ); + await asyncForEach(testDataList, async ({ calendarId }) => { + await ml.settingsCalendar.assertCalendarRowNotExists(calendarId); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/calendar_edit.ts b/x-pack/test/functional/apps/ml/settings/calendar_edit.ts new file mode 100644 index 00000000000000..f7c8c1f6f85f5b --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/calendar_edit.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { asyncForEach, createJobConfig } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + const esArchiver = getService('esArchiver'); + const comboBox = getService('comboBox'); + + const calendarId = 'test_edit_calendar_id'; + const testEvents = [ + { description: 'event_1', start_time: 1513641600000, end_time: 1513728000000 }, + { description: 'event_2', start_time: 1513814400000, end_time: 1513900800000 }, + ]; + const jobConfigs = [createJobConfig('test_calendar_ad_1'), createJobConfig('test_calendar_ad_2')]; + const newJobGroups = ['farequote']; + + describe('calendar edit', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + + await asyncForEach(jobConfigs, async (jobConfig) => { + await ml.api.createAnomalyDetectionJob(jobConfig); + }); + + await ml.api.createCalendar(calendarId, { + job_ids: jobConfigs.map((c) => c.job_id), + description: 'Test calendar', + }); + await ml.api.createCalendarEvents(calendarId, testEvents); + + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('updates jobs, groups and events', async () => { + await ml.testExecution.logTestStep('calendar edit loads the calendar management page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToCalendarManagement(); + + await ml.testExecution.logTestStep('calendar edit opens existing calendar'); + await ml.settingsCalendar.openCalendarEditForm(calendarId); + + await ml.testExecution.logTestStep( + 'calendar edit deselects previous job selection and assigns new job groups' + ); + await comboBox.clear('mlCalendarJobSelection'); + await asyncForEach(newJobGroups, async (newJobGroup) => { + await ml.settingsCalendar.selectJobGroup(newJobGroup); + }); + + await ml.testExecution.logTestStep('calendar edit deletes old events'); + + await asyncForEach(testEvents, async ({ description }) => { + await ml.settingsCalendar.deleteCalendarEventRow(description); + }); + + await ml.testExecution.logTestStep('calendar edit creates new calendar event'); + await ml.settingsCalendar.openNewCalendarEventForm(); + await ml.settingsCalendar.setCalendarEventDescription('holiday'); + await ml.settingsCalendar.addNewCalendarEvent(); + await ml.settingsCalendar.assertEventRowExists('holiday'); + + await ml.testExecution.logTestStep( + 'calendar edit saves the new calendar and displays it in the list of calendars ' + ); + await ml.settingsCalendar.saveCalendar(); + await ml.settingsCalendar.assertCalendarRowExists(calendarId); + + await ml.testExecution.logTestStep('calendar edit re-opens the updated calendar'); + await ml.settingsCalendar.openCalendarEditForm(calendarId); + await ml.testExecution.logTestStep('calendar edit verifies the job selection is empty'); + await ml.settingsCalendar.assertJobSelection([]); + await ml.testExecution.logTestStep( + 'calendar edit verifies the job group selection was updated' + ); + await ml.settingsCalendar.assertJobGroupSelection(newJobGroups); + + await ml.testExecution.logTestStep('calendar edit verifies calendar updated correctly'); + await asyncForEach(testEvents, async ({ description }) => { + await ml.settingsCalendar.assertEventRowMissing(description); + }); + await ml.settingsCalendar.assertEventRowExists('holiday'); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/common.ts b/x-pack/test/functional/apps/ml/settings/common.ts new file mode 100644 index 00000000000000..9fada028ff3da8 --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/common.ts @@ -0,0 +1,26 @@ +/* + * 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 async function asyncForEach(array: T[], callback: (item: T, index: number) => void) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index); + } +} + +export const createJobConfig = (jobId: string) => ({ + job_id: jobId, + description: + 'mean/min/max(responsetime) partition=airline on farequote dataset with 1h bucket span', + groups: ['farequote', 'automated', 'multi-metric'], + analysis_config: { + bucket_span: '1h', + influencers: ['airline'], + detectors: [{ function: 'mean', field_name: 'responsetime', partition_field_name: 'airline' }], + }, + data_description: { time_field: '@timestamp' }, + analysis_limits: { model_memory_limit: '20mb' }, + model_plot_config: { enabled: false }, +}); diff --git a/x-pack/test/functional/apps/ml/settings/filter_list_creation.ts b/x-pack/test/functional/apps/ml/settings/filter_list_creation.ts new file mode 100644 index 00000000000000..22affa1cada38b --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/filter_list_creation.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + const filterId = 'test_create_filter'; + const description = 'test description'; + const keywords = ['filter word 1', 'filter word 2', 'filter word 3']; + + describe('filter list creation', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + // clean up created filters + await ml.api.deleteFilter(filterId); + }); + + it('creates new filter list', async () => { + await ml.testExecution.logTestStep( + 'filter list creation loads the filter list management page' + ); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToFilterListsManagement(); + + await ml.testExecution.logTestStep('filter list creation loads the filter creation page'); + await ml.settingsFilterList.navigateToFilterListCreationPage(); + + await ml.testExecution.logTestStep('filter list creation sets the list name and description'); + await ml.settingsFilterList.setFilterListId(filterId); + await ml.settingsFilterList.setFilterListDescription(description); + + await ml.testExecution.logTestStep('filter list creation adds items to the filter list'); + await ml.settingsFilterList.addFilterListKeywords(keywords); + await ml.testExecution.logTestStep('filter list creation saves the settings'); + await ml.settingsFilterList.saveFilterList(); + await ml.settingsFilterList.assertFilterListRowExists(filterId); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/filter_list_delete.ts b/x-pack/test/functional/apps/ml/settings/filter_list_delete.ts new file mode 100644 index 00000000000000..9e30d2c8915d26 --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/filter_list_delete.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { asyncForEach } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + + const testDataList = [1, 2].map((n) => ({ + filterId: `test_delete_filter_${n}`, + description: `test description ${n}`, + items: ['filter word 1', 'filter word 2', 'filter word 3'], + })); + + describe('filter list delete', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + + for (let index = 0; index < testDataList.length; index++) { + const { filterId, description, items } = testDataList[index]; + + await ml.api.createFilter(filterId, { + description, + items, + }); + } + }); + + after(async () => { + await ml.api.cleanMlIndices(); + + // clean up created filters + await asyncForEach(testDataList, async ({ filterId }) => { + await ml.api.deleteFilter(filterId); + }); + }); + + it('deletes filter list with items', async () => { + await ml.testExecution.logTestStep( + 'filter list delete loads the filter list management page' + ); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToFilterListsManagement(); + + await ml.testExecution.logTestStep( + 'filter list delete selects list entries and deletes them' + ); + for (const testData of testDataList) { + const { filterId } = testData; + await ml.settingsFilterList.selectFilterListRow(filterId); + } + await ml.settingsFilterList.deleteFilterList(); + + await ml.testExecution.logTestStep( + 'filter list delete validates selected filter lists are deleted' + ); + await asyncForEach(testDataList, async ({ filterId }) => { + await ml.settingsFilterList.assertFilterListRowNotExists(filterId); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/filter_list_edit.ts b/x-pack/test/functional/apps/ml/settings/filter_list_edit.ts new file mode 100644 index 00000000000000..8c39c679ac6f2a --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/filter_list_edit.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { asyncForEach } from './common'; + +export default function ({ getService }: FtrProviderContext) { + const ml = getService('ml'); + + const filterId = 'test_filter_list_edit'; + const keywordToDelete = 'keyword_to_delete'; + const oldKeyword = 'old_keyword'; + const oldDescription = 'Old filter list description'; + + const newKeywords = ['new_keyword1', 'new_keyword2']; + const newDescription = 'New filter list description'; + + describe('filter list edit', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + + await ml.api.createFilter(filterId, { + description: oldDescription, + items: [keywordToDelete, oldKeyword], + }); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.api.deleteFilter(filterId); + }); + + it('updates description and filter items', async () => { + await ml.testExecution.logTestStep('filter list edit loads the filter list management page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToSettings(); + await ml.settings.navigateToFilterListsManagement(); + + await ml.testExecution.logTestStep('filter list edit opens existing filter list'); + await ml.settingsFilterList.selectFilterListRowEditLink(filterId); + await ml.settingsFilterList.assertFilterItemExists(keywordToDelete); + await ml.settingsFilterList.assertFilterListDescriptionValue(oldDescription); + + await ml.testExecution.logTestStep('filter list edit deletes existing filter item'); + await ml.settingsFilterList.deleteFilterItem(keywordToDelete); + + await ml.testExecution.logTestStep('filter list edit sets new keywords and description'); + await ml.settingsFilterList.setFilterListDescription(newDescription); + await ml.settingsFilterList.addFilterListKeywords(newKeywords); + + await ml.testExecution.logTestStep( + 'filter list edit saves the new filter list and displays it in the list of entries' + ); + await ml.settingsFilterList.saveFilterList(); + await ml.settingsFilterList.assertFilterListRowExists(filterId); + + await ml.testExecution.logTestStep('filter list edit reopens the edited filter list'); + await ml.settingsFilterList.selectFilterListRowEditLink(filterId); + + await ml.testExecution.logTestStep( + 'filter list edit verifies the filter list description updated correctly' + ); + await ml.settingsFilterList.assertFilterListDescriptionValue(newDescription); + + await ml.testExecution.logTestStep( + 'filter list edit verifies the filter items updated correctly' + ); + await ml.settingsFilterList.assertFilterItemNotExists(keywordToDelete); + await asyncForEach([...newKeywords, oldKeyword], async (filterItem) => { + await ml.settingsFilterList.assertFilterItemExists(filterItem); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/settings/index.ts b/x-pack/test/functional/apps/ml/settings/index.ts new file mode 100644 index 00000000000000..5b2c7d15e19595 --- /dev/null +++ b/x-pack/test/functional/apps/ml/settings/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('settings', function () { + this.tags(['quynh', 'skipFirefox']); + + loadTestFile(require.resolve('./calendar_creation')); + loadTestFile(require.resolve('./calendar_edit')); + loadTestFile(require.resolve('./calendar_delete')); + + loadTestFile(require.resolve('./filter_list_creation')); + loadTestFile(require.resolve('./filter_list_edit')); + loadTestFile(require.resolve('./filter_list_delete')); + }); +} diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 325ea41ae3977b..50da8425e493d6 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -78,8 +78,8 @@ export function MachineLearningProvider(context: FtrProviderContext) { const securityCommon = MachineLearningSecurityCommonProvider(context); const securityUI = MachineLearningSecurityUIProvider(context, securityCommon); const settings = MachineLearningSettingsProvider(context); - const settingsCalendar = MachineLearningSettingsCalendarProvider(context); - const settingsFilterList = MachineLearningSettingsFilterListProvider(context); + const settingsCalendar = MachineLearningSettingsCalendarProvider(context, commonUI); + const settingsFilterList = MachineLearningSettingsFilterListProvider(context, commonUI); const singleMetricViewer = MachineLearningSingleMetricViewerProvider(context); const testExecution = MachineLearningTestExecutionProvider(context); const testResources = MachineLearningTestResourcesProvider(context); diff --git a/x-pack/test/functional/services/ml/security_ui.ts b/x-pack/test/functional/services/ml/security_ui.ts index e09467ff36a34c..da4324901d38e9 100644 --- a/x-pack/test/functional/services/ml/security_ui.ts +++ b/x-pack/test/functional/services/ml/security_ui.ts @@ -16,7 +16,6 @@ export function MachineLearningSecurityUIProvider( return { async loginAs(user: USER) { const password = mlSecurityCommon.getPasswordForUser(user); - await PageObjects.security.forceLogout(); await PageObjects.security.login(user, password, { diff --git a/x-pack/test/functional/services/ml/settings_calendar.ts b/x-pack/test/functional/services/ml/settings_calendar.ts index 34d18c6e12c474..c269636522923f 100644 --- a/x-pack/test/functional/services/ml/settings_calendar.ts +++ b/x-pack/test/functional/services/ml/settings_calendar.ts @@ -7,9 +7,15 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommonUI } from './common_ui'; -export function MachineLearningSettingsCalendarProvider({ getService }: FtrProviderContext) { +export function MachineLearningSettingsCalendarProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const comboBox = getService('comboBox'); return { async parseCalendarTable() { @@ -172,6 +178,11 @@ export function MachineLearningSettingsCalendarProvider({ getService }: FtrProvi ); }, + calendarRowSelector(calendarId: string, subSelector?: string) { + const row = `~mlCalendarTable > ~row-${calendarId}`; + return !subSelector ? row : `${row} > ${subSelector}`; + }, + eventRowSelector(eventDescription: string, subSelector?: string) { const row = `~mlCalendarEventsTable > ~row-${eventDescription}`; return !subSelector ? row : `${row} > ${subSelector}`; @@ -181,6 +192,10 @@ export function MachineLearningSettingsCalendarProvider({ getService }: FtrProvi await testSubjects.existOrFail(this.eventRowSelector(eventDescription)); }, + async assertEventRowMissing(eventDescription: string) { + await testSubjects.missingOrFail(this.eventRowSelector(eventDescription)); + }, + async assertDeleteEventButtonEnabled(eventDescription: string, expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled( this.eventRowSelector(eventDescription, 'mlCalendarEventDeleteButton') @@ -192,5 +207,191 @@ export function MachineLearningSettingsCalendarProvider({ getService }: FtrProvi }' (got '${isEnabled ? 'enabled' : 'disabled'}')` ); }, + + async assertCalendarRowExists(calendarId: string) { + await testSubjects.existOrFail(this.calendarRowSelector(calendarId)); + }, + + async assertCalendarRowNotExists(calendarId: string) { + await testSubjects.missingOrFail(this.calendarRowSelector(calendarId)); + }, + + async assertCalendarIdValue(expectedValue: string) { + const actualValue = await testSubjects.getAttribute('mlCalendarIdInput', 'value'); + expect(actualValue).to.eql( + expectedValue, + `Calendar id should be '${expectedValue}' (got '${actualValue}')` + ); + }, + + async setCalendarId(calendarId: string) { + await mlCommonUI.setValueWithChecks('mlCalendarIdInput', calendarId, { + clearWithKeyboard: true, + }); + await this.assertCalendarIdValue(calendarId); + }, + + async assertCalendarDescriptionValue(expectedValue: string) { + const actualValue = await testSubjects.getAttribute('mlCalendarDescriptionInput', 'value'); + expect(actualValue).to.eql( + expectedValue, + `Calendar description should be '${expectedValue}' (got '${actualValue}')` + ); + }, + + async setCalendarDescription(description: string) { + await mlCommonUI.setValueWithChecks('mlCalendarDescriptionInput', description, { + clearWithKeyboard: true, + }); + await this.assertCalendarDescriptionValue(description); + }, + + async getApplyToAllJobsSwitchCheckedState(): Promise { + const subj = 'mlCalendarApplyToAllJobsSwitch'; + const isSelected = await testSubjects.getAttribute(subj, 'aria-checked'); + return isSelected === 'true'; + }, + + async toggleApplyToAllJobsSwitch(toggle: boolean) { + const subj = 'mlCalendarApplyToAllJobsSwitch'; + if ((await this.getApplyToAllJobsSwitchCheckedState()) !== toggle) { + await retry.tryForTime(5 * 1000, async () => { + await testSubjects.clickWhenNotDisabled(subj); + await this.assertApplyToAllJobsSwitchEnabled(toggle); + }); + } + }, + + async saveCalendar() { + await testSubjects.existOrFail('mlSaveCalendarButton'); + await testSubjects.click('mlSaveCalendarButton'); + await testSubjects.existOrFail('mlPageCalendarManagement'); + }, + + async navigateToCalendarCreationPage() { + await testSubjects.existOrFail('mlCalendarButtonCreate'); + await testSubjects.click('mlCalendarButtonCreate'); + await testSubjects.existOrFail('mlPageCalendarEdit'); + }, + + async openNewCalendarEventForm() { + await testSubjects.existOrFail('mlCalendarNewEventButton'); + await testSubjects.click('mlCalendarNewEventButton'); + await testSubjects.existOrFail('mlPageCalendarEdit'); + }, + + async assertCalendarEventDescriptionValue(expectedValue: string) { + const actualValue = await testSubjects.getAttribute( + 'mlCalendarEventDescriptionInput', + 'value' + ); + expect(actualValue).to.eql( + expectedValue, + `Calendar event description should be '${expectedValue}' (got '${actualValue}')` + ); + }, + + async setCalendarEventDescription(eventDescription: string) { + await testSubjects.existOrFail('mlCalendarEventDescriptionInput'); + await mlCommonUI.setValueWithChecks('mlCalendarEventDescriptionInput', eventDescription, { + clearWithKeyboard: true, + }); + await this.assertCalendarEventDescriptionValue(eventDescription); + }, + + async cancelNewCalendarEvent() { + await testSubjects.existOrFail('mlCalendarCancelEventButton'); + await testSubjects.click('mlCalendarCancelEventButton'); + await testSubjects.missingOrFail('mlCalendarEventForm'); + }, + + async addNewCalendarEvent() { + await testSubjects.existOrFail('mlCalendarAddEventButton'); + await testSubjects.click('mlCalendarAddEventButton'); + await testSubjects.missingOrFail('mlCalendarEventForm'); + }, + + async assertJobSelectionExists() { + await testSubjects.existOrFail('mlCalendarJobSelection'); + }, + + async assertJobSelectionNotExists() { + await testSubjects.missingOrFail('mlCalendarJobSelection'); + }, + + async assertJobSelection(expectedIdentifier: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlCalendarJobSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier, + `Expected job selection to be '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async assertJobSelectionContain(expectedIdentifier: string) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlCalendarJobSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.contain( + expectedIdentifier, + `Expected job selection to contain '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectJob(identifier: string) { + await comboBox.set('mlCalendarJobSelection > comboBoxInput', identifier); + await this.assertJobSelectionContain(identifier); + }, + + async assertJobGroupSelectionExists() { + await testSubjects.existOrFail('mlCalendarJobGroupSelection'); + }, + + async assertJobGroupSelectionNotExists() { + await testSubjects.missingOrFail('mlCalendarJobGroupSelection'); + }, + + async assertJobGroupSelection(expectedIdentifier: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlCalendarJobGroupSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier, + `Expected job group selection to be'${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async assertJobGroupSelectionContain(expectedIdentifier: string) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + 'mlCalendarJobGroupSelection > comboBoxInput' + ); + expect(comboBoxSelectedOptions).to.contain( + expectedIdentifier, + `Expected job group selection to contain'${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectJobGroup(identifier: string) { + await comboBox.set('mlCalendarJobGroupSelection > comboBoxInput', identifier); + await this.assertJobGroupSelectionContain(identifier); + }, + + async deleteCalendarEventRow(eventDescription: string) { + await this.assertEventRowExists(eventDescription); + await testSubjects.click( + this.eventRowSelector(eventDescription, 'mlCalendarEventDeleteButton') + ); + await this.assertEventRowMissing(eventDescription); + }, + + async deleteCalendar() { + await this.assertDeleteCalendarButtonEnabled(true); + await testSubjects.click('mlCalendarButtonDelete'); + await testSubjects.existOrFail('mlCalendarDeleteConfirmation'); + await testSubjects.existOrFail('confirmModalConfirmButton'); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.missingOrFail('mlCalendarDeleteConfirmation'); + }, }; } diff --git a/x-pack/test/functional/services/ml/settings_filter_list.ts b/x-pack/test/functional/services/ml/settings_filter_list.ts index 0afe9f21b03a69..bcac575b65c08e 100644 --- a/x-pack/test/functional/services/ml/settings_filter_list.ts +++ b/x-pack/test/functional/services/ml/settings_filter_list.ts @@ -7,9 +7,14 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlCommonUI } from './common_ui'; -export function MachineLearningSettingsFilterListProvider({ getService }: FtrProviderContext) { +export function MachineLearningSettingsFilterListProvider( + { getService }: FtrProviderContext, + mlCommonUI: MlCommonUI +) { const testSubjects = getService('testSubjects'); + const browser = getService('browser'); return { async parseFilterListTable() { @@ -17,7 +22,7 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro const $ = await table.parseDomContent(); const rows = []; - for (const tr of $.findTestSubjects('~mlFilterListsRow').toArray()) { + for (const tr of $.findTestSubjects('~mlFilterListRow').toArray()) { const $tr = $(tr); const inUseSubject = $tr @@ -55,6 +60,14 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro return !subSelector ? row : `${row} > ${subSelector}`; }, + async assertFilterListRowExists(filterId: string) { + return await testSubjects.existOrFail(this.rowSelector(filterId)); + }, + + async assertFilterListRowNotExists(filterId: string) { + return await testSubjects.missingOrFail(this.rowSelector(filterId)); + }, + async filterWithSearchString(filter: string, expectedRowCount: number = 1) { const tableListContainer = await testSubjects.find('mlFilterListTableContainer'); const searchBarInput = await tableListContainer.findByClassName('euiFieldSearch'); @@ -101,6 +114,12 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro await this.assertFilterListRowSelected(filterId, false); }, + async selectFilterListRowEditLink(filterId: string) { + await this.assertFilterListRowExists(filterId); + await testSubjects.click(this.rowSelector(filterId, `mlEditFilterListLink`)); + await testSubjects.existOrFail('mlPageFilterListEdit'); + }, + async assertCreateFilterListButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled('mlFilterListsButtonCreate'); expect(isEnabled).to.eql( @@ -111,6 +130,10 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro ); }, + async assertDeleteFilterListButtonExists() { + await testSubjects.existOrFail('mlFilterListsDeleteButton'); + }, + async assertDeleteFilterListButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled('mlFilterListsDeleteButton'); expect(isEnabled).to.eql( @@ -121,6 +144,16 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro ); }, + async deleteFilterList() { + await this.assertDeleteFilterListButtonExists(); + await this.assertDeleteFilterListButtonEnabled(true); + await testSubjects.click('mlFilterListsDeleteButton'); + await testSubjects.existOrFail('mlFilterListsDeleteButton'); + await testSubjects.existOrFail('mlFilterListDeleteConfirmation'); + await testSubjects.click('confirmModalConfirmButton'); + await testSubjects.missingOrFail('mlFilterListDeleteConfirmation'); + }, + async openFilterListEditForm(filterId: string) { await testSubjects.click(this.rowSelector(filterId, 'mlEditFilterListLink')); await testSubjects.existOrFail('mlPageFilterListEdit'); @@ -136,8 +169,8 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro ); }, - async assertAddItemButtonEnabled(expectedValue: boolean) { - const isEnabled = await testSubjects.isEnabled('mlFilterListAddItemButton'); + async assertOpenNewItemsPopoverButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled('mlFilterListOpenNewItemsPopoverButton'); expect(isEnabled).to.eql( expectedValue, `Expected "add item" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ @@ -146,6 +179,16 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro ); }, + async assertAddItemsButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled('mlFilterListOpenNewItemsPopoverButton'); + expect(isEnabled).to.eql( + expectedValue, + `Expected "add" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ + isEnabled ? 'enabled' : 'disabled' + }')` + ); + }, + async assertDeleteItemButtonEnabled(expectedValue: boolean) { const isEnabled = await testSubjects.isEnabled('mlFilterListDeleteItemButton'); expect(isEnabled).to.eql( @@ -156,11 +199,25 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro ); }, + async assertSaveFilterListButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled('mlFilterListSaveButton'); + expect(isEnabled).to.eql( + expectedValue, + `Expected "save filter list" button to be '${ + expectedValue ? 'enabled' : 'disabled' + }' (got '${isEnabled ? 'enabled' : 'disabled'}')` + ); + }, + filterItemSelector(filterItem: string, subSelector?: string) { const row = `mlGridItem ${filterItem}`; return !subSelector ? row : `${row} > ${subSelector}`; }, + async assertFilterItemNotExists(filterItem: string) { + await testSubjects.missingOrFail(this.filterItemSelector(filterItem)); + }, + async assertFilterItemExists(filterItem: string) { await testSubjects.existOrFail(this.filterItemSelector(filterItem)); }, @@ -189,6 +246,13 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro await this.assertFilterItemSelected(filterItem, true); }, + async deleteFilterItem(filterItem: string) { + await testSubjects.existOrFail('mlFilterListDeleteItemButton'); + await this.selectFilterItem(filterItem); + await testSubjects.click('mlFilterListDeleteItemButton'); + await this.assertFilterItemNotExists(filterItem); + }, + async deselectFilterItem(filterItem: string) { if ((await this.isFilterItemSelected(filterItem)) === true) { await testSubjects.click(this.filterItemSelector(filterItem)); @@ -196,5 +260,70 @@ export function MachineLearningSettingsFilterListProvider({ getService }: FtrPro await this.assertFilterItemSelected(filterItem, false); }, + + async navigateToFilterListCreationPage() { + await this.assertCreateFilterListButtonEnabled(true); + await testSubjects.click('mlFilterListsButtonCreate'); + await testSubjects.existOrFail('mlPageFilterListEdit'); + }, + + async assertFilterListIdValue(expectedValue: string) { + const subj = 'mlNewFilterListIdInput'; + const actualFilterListId = await testSubjects.getAttribute(subj, 'value'); + expect(actualFilterListId).to.eql( + expectedValue, + `Filter list id should be '${expectedValue}' (got '${actualFilterListId}')` + ); + }, + + async setFilterListId(filterId: string) { + const subj = 'mlNewFilterListIdInput'; + await mlCommonUI.setValueWithChecks(subj, filterId, { + clearWithKeyboard: true, + }); + await this.assertFilterListIdValue(filterId); + }, + + async setFilterListDescription(description: string) { + await this.assertEditDescriptionButtonEnabled(true); + await testSubjects.click('mlFilterListEditDescriptionButton'); + await testSubjects.existOrFail('mlFilterListDescriptionInput'); + await mlCommonUI.setValueWithChecks('mlFilterListDescriptionInput', description, { + clearWithKeyboard: true, + }); + await browser.pressKeys(browser.keys.ESCAPE); + await this.assertFilterListDescriptionValue(description); + }, + + async addFilterListKeywords(keywords: string[]) { + await this.assertOpenNewItemsPopoverButtonEnabled(true); + await testSubjects.click('mlFilterListOpenNewItemsPopoverButton'); + await mlCommonUI.setValueWithChecks('mlFilterListAddItemTextArea', keywords.join('\n'), { + clearWithKeyboard: true, + }); + await testSubjects.existOrFail('mlFilterListAddItemsButton'); + await this.assertAddItemsButtonEnabled(true); + await testSubjects.click('mlFilterListAddItemsButton'); + + for (let index = 0; index < keywords.length; index++) { + await this.assertFilterItemExists(keywords[index]); + } + }, + + async assertFilterListDescriptionValue(expectedDescription: string) { + const actualFilterListDescription = await testSubjects.getVisibleText( + 'mlNewFilterListDescriptionText' + ); + expect(actualFilterListDescription).to.eql( + expectedDescription, + `Filter list description should be '${expectedDescription}' (got '${actualFilterListDescription}')` + ); + }, + + async saveFilterList() { + await this.assertSaveFilterListButtonEnabled(true); + await testSubjects.click('mlFilterListSaveButton'); + await testSubjects.existOrFail('mlPageFilterListManagement'); + }, }; } From 42026cbbf54a7109b823567b689650066db36216 Mon Sep 17 00:00:00 2001 From: Constance Date: Tue, 22 Sep 2020 12:33:37 -0700 Subject: [PATCH 14/15] [Enterprise Search] Move http out of React Context and to Kea Logic; prefer directly mounting (#78167) * Remove HttpProvider in favor of mounting HttpLogic directly w/ props - removes need for initializeHttp call - ensures http value is loaded into HttpLogic as soon as possible / should never load in as null, reducing # of rerenders/checks see: https://kea.js.org/docs/guide/advanced#mounting-and-unmounting * Update simplest components using http for sendTelemetry * Update simplest tests for components using HttpLogic + default Kea mocks - Kea mock import should now contain mock default values which can be overridden * Update moderately complex tests using HttpLogic send_telemetry: - refactor to use shallow (w/ useEffect mocked) vs mount - check mockHttpValues directly engine_table: - refactor to use mount w/ an I18nProvider rather than mountWithContext helper (which we'll likely need to overhaul in the future) - assert mockHttpValues directly * Update EngineOverview to HttpLogic + refactors EngineOverview: - Change use of FormattedMessage to i18n.translate (simpler, no provider required) Tests: - Create mock values/actions for FlashMessages, since EngineOverview calls it - Create combined mockAllValues obj for easier overriding - Create setMockValues helper for easier test overriding (credit to @scottybollinger for the idea!) - Update engine_overview tests to setMockValues instead of passing context to mountWithAsyncContext - Fix mountWithAsyncContext to accept an undefined obj * Remove http from KibanaContext - it should now only live in HttpLogic :fire: * Remove FlashMessagesProvider in favor of mounting logic directly w/ props - send history as prop - refactor out now-unnecessary listenToHistory (we can just do it directly in afterMount without worrying about duplicate react rerenders) - add mount helper Tests: - refactor history.listen mock to mockHistory (so that set_message_helpers can use it as well) - use mountFlashMessagesLogic + create an even shorter mount() helper (credit to @JasonStoltz for the idea!) - refactor out DEFAULT_VALUES since we're not really using it anywhere else in the file, and it's not super applicable to this store - update history listener tests to account for logic occurring immediately on mount --- .../__mocks__/flash_messages_logic.mock.ts | 17 ++++++ .../applications/__mocks__/http_logic.mock.ts | 13 +++++ .../public/applications/__mocks__/index.ts | 4 ++ .../public/applications/__mocks__/kea.mock.ts | 39 ++++++++++--- .../__mocks__/kibana_context.mock.ts | 2 - .../__mocks__/mount_with_context.mock.tsx | 2 +- .../__mocks__/react_router_history.mock.ts | 1 + .../components/empty_state.test.tsx | 1 + .../components/empty_state.tsx | 4 +- .../components/header.test.tsx | 1 + .../engine_overview/components/header.tsx | 4 +- .../engine_overview/engine_overview.test.tsx | 24 +++----- .../engine_overview/engine_overview.tsx | 21 ++++--- .../engine_overview/engine_table.test.tsx | 53 ++++++++++------- .../engine_overview/engine_table.tsx | 4 +- .../product_card/product_card.test.tsx | 2 + .../components/product_card/product_card.tsx | 7 ++- .../public/applications/index.tsx | 30 +++++----- .../flash_messages_logic.test.ts | 58 +++++++++---------- .../flash_messages/flash_messages_logic.ts | 22 ++++--- .../flash_messages_provider.test.tsx | 46 --------------- .../flash_messages_provider.tsx | 26 --------- .../shared/flash_messages/index.ts | 2 +- .../set_message_helpers.test.ts | 5 +- .../shared/http/http_logic.test.ts | 37 +++++------- .../applications/shared/http/http_logic.ts | 39 ++++++++----- .../shared/http/http_provider.test.tsx | 45 -------------- .../shared/http/http_provider.tsx | 29 ---------- .../public/applications/shared/http/index.ts | 3 +- .../shared/telemetry/send_telemetry.test.tsx | 31 +++++----- .../shared/telemetry/send_telemetry.tsx | 11 ++-- .../product_button/product_button.test.tsx | 1 + .../shared/product_button/product_button.tsx | 4 +- .../views/overview/onboarding_card.test.tsx | 1 + .../views/overview/onboarding_card.tsx | 5 +- .../views/overview/onboarding_steps.tsx | 3 +- .../views/overview/recent_activity.tsx | 3 +- 37 files changed, 268 insertions(+), 332 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/__mocks__/http_logic.mock.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.ts new file mode 100644 index 00000000000000..a610ea0238ac00 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/flash_messages_logic.mock.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. + */ + +export const mockFlashMessagesValues = { + messages: [], + queuedMessages: [], +}; + +export const mockFlashMessagesActions = { + setFlashMessages: jest.fn(), + clearFlashMessages: jest.fn(), + setQueuedMessages: jest.fn(), + clearQueuedMessages: jest.fn(), +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/http_logic.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/http_logic.mock.ts new file mode 100644 index 00000000000000..e77863c70c23a9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/http_logic.mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { httpServiceMock } from 'src/core/public/mocks'; + +export const mockHttpValues = { + http: httpServiceMock.createSetupContract(), + errorConnecting: false, + readOnlyMode: false, +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index e999d40a3f8e66..f66235ff44c6aa 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -7,6 +7,10 @@ export { mockHistory, mockLocation } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; export { mockLicenseContext } from './license_context.mock'; +export { mockHttpValues } from './http_logic.mock'; +export { mockFlashMessagesValues, mockFlashMessagesActions } from './flash_messages_logic.mock'; +export { mockAllValues, mockAllActions, setMockValues } from './kea.mock'; + export { mountWithContext, mountWithKibanaContext, diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts index 5049e9da21ce9b..8e6b0baa5fc00f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kea.mock.ts @@ -4,21 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * NOTE: These variable names MUST start with 'mock*' in order for + * Jest to accept its use within a jest.mock() + */ +import { mockHttpValues } from './http_logic.mock'; +import { mockFlashMessagesValues, mockFlashMessagesActions } from './flash_messages_logic.mock'; + +export const mockAllValues = { + ...mockHttpValues, + ...mockFlashMessagesValues, +}; +export const mockAllActions = { + ...mockFlashMessagesActions, +}; + +/** + * Import this file directly to mock useValues with a set of default values for all shared logic files. + * Example usage: + * + * import '../../../__mocks__/kea'; // Must come before kea's import, adjust relative path as needed + */ jest.mock('kea', () => ({ ...(jest.requireActual('kea') as object), - useValues: jest.fn(() => ({})), - useActions: jest.fn(() => ({})), + useValues: jest.fn(() => ({ ...mockAllValues })), + useActions: jest.fn(() => ({ ...mockAllActions })), })); /** + * Call this function to override a specific set of Kea values while retaining all other defaults * Example usage within a component test: * - * import '../../../__mocks__/kea'; // Must come before kea's import, adjust relative path as needed - * - * import { useActions, useValues } from 'kea'; + * import '../../../__mocks__/kea'; + * import { setMockValues } from ''../../../__mocks__'; * * it('some test', () => { - * (useValues as jest.Mock).mockImplementationOnce(() => ({ someValue: 'hello' })); - * (useActions as jest.Mock).mockImplementationOnce(() => ({ someAction: () => 'world' })); + * setMockValues({ someValue: 'hello' }); * }); */ +import { useValues } from 'kea'; + +export const setMockValues = (values: object) => { + (useValues as jest.Mock).mockImplementation(() => ({ ...mockAllValues, ...values })); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts index 890072ab42eb92..ea3c3923cc4720 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/kibana_context.mock.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { httpServiceMock } from 'src/core/public/mocks'; import { ExternalUrl } from '../shared/enterprise_search_url'; /** @@ -12,7 +11,6 @@ import { ExternalUrl } from '../shared/enterprise_search_url'; * @see enterprise_search/public/index.tsx for the KibanaContext definition/import */ export const mockKibanaContext = { - http: httpServiceMock.createSetupContract(), navigateToUrl: jest.fn(), setBreadcrumbs: jest.fn(), setDocTitle: jest.fn(), diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index 826e0482acef73..5e56f17c8e7f31 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -67,7 +67,7 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje */ export const mountWithAsyncContext = async ( children: React.ReactNode, - context: object + context?: object ): Promise => { let wrapper: ReactWrapper | undefined; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts index 842dcefd3aef8a..7b3ac86ad0ab1f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/react_router_history.mock.ts @@ -14,6 +14,7 @@ export const mockHistory = { location: { pathname: '/current-path', }, + listen: jest.fn(() => jest.fn()), }; export const mockLocation = { key: 'someKey', diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx index 7e6876bc9b3a40..233db7d4c59177 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx index 58691cf09b4a53..5ed1f0b277306b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/empty_state.tsx @@ -5,10 +5,12 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiPageContent, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../../shared/telemetry'; +import { HttpLogic } from '../../../../shared/http'; import { SetAppSearchChrome as SetPageChrome } from '../../../../shared/kibana_chrome'; import { KibanaContext, IKibanaContext } from '../../../../index'; import { CREATE_ENGINES_PATH } from '../../../routes'; @@ -18,9 +20,9 @@ import { EngineOverviewHeader } from './header'; import './empty_state.scss'; export const EmptyState: React.FC = () => { + const { http } = useValues(HttpLogic); const { externalUrl: { getAppSearchUrl }, - http, } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx index 7f22ce132d405f..8c7dfa2b7c3d63 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx index 1a1ae295d48284..dca0d45a207b4a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/header.tsx @@ -5,6 +5,7 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiPageHeader, EuiPageHeaderSection, @@ -16,12 +17,13 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../../shared/telemetry'; +import { HttpLogic } from '../../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../../index'; export const EngineOverviewHeader: React.FC = () => { + const { http } = useValues(HttpLogic); const { externalUrl: { getAppSearchUrl }, - http, } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index c2379fb33bd713..928d92d7910940 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../__mocks__/kea.mock'; import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { shallow, ReactWrapper } from 'enzyme'; -import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; +import { mountWithAsyncContext, mockHttpValues, setMockValues } from '../../../__mocks__'; import { LoadingState, EmptyState } from './components'; import { EngineTable } from './engine_table'; @@ -18,8 +19,6 @@ import { EngineTable } from './engine_table'; import { EngineOverview } from './'; describe('EngineOverview', () => { - const mockHttp = mockKibanaContext.http; - describe('non-happy-path states', () => { it('isLoading', () => { const wrapper = shallow(); @@ -28,15 +27,16 @@ describe('EngineOverview', () => { }); it('isEmpty', async () => { - const wrapper = await mountWithAsyncContext(, { + setMockValues({ http: { - ...mockHttp, + ...mockHttpValues.http, get: () => ({ results: [], meta: { page: { total_results: 0 } }, }), }, }); + const wrapper = await mountWithAsyncContext(); expect(wrapper.find(EmptyState)).toHaveLength(1); }); @@ -65,12 +65,11 @@ describe('EngineOverview', () => { beforeEach(() => { jest.clearAllMocks(); + setMockValues({ http: { ...mockHttpValues.http, get: mockApi } }); }); it('renders and calls the engines API', async () => { - const wrapper = await mountWithAsyncContext(, { - http: { ...mockHttp, get: mockApi }, - }); + const wrapper = await mountWithAsyncContext(); expect(wrapper.find(EngineTable)).toHaveLength(1); expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { @@ -84,7 +83,6 @@ describe('EngineOverview', () => { describe('when on a platinum license', () => { it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { const wrapper = await mountWithAsyncContext(, { - http: { ...mockHttp, get: mockApi }, license: { type: 'platinum', isActive: true }, }); @@ -103,9 +101,7 @@ describe('EngineOverview', () => { wrapper.find(EngineTable).prop('pagination'); it('passes down page data from the API', async () => { - const wrapper = await mountWithAsyncContext(, { - http: { ...mockHttp, get: mockApi }, - }); + const wrapper = await mountWithAsyncContext(); const pagination = getTablePagination(wrapper); expect(pagination.totalEngines).toEqual(100); @@ -113,9 +109,7 @@ describe('EngineOverview', () => { }); it('re-polls the API on page change', async () => { - const wrapper = await mountWithAsyncContext(, { - http: { ...mockHttp, get: mockApi }, - }); + const wrapper = await mountWithAsyncContext(); await act(async () => getTablePagination(wrapper).onPaginate(5)); wrapper.update(); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx index 9703fde7e140af..c0aedbe7dc6b42 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.tsx @@ -5,6 +5,7 @@ */ import React, { useContext, useEffect, useState } from 'react'; +import { useValues } from 'kea'; import { EuiPageContent, EuiPageContentHeader, @@ -12,13 +13,13 @@ import { EuiTitle, EuiSpacer, } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; import { FlashMessages } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; import { LicenseContext, ILicenseContext, hasPlatinumLicense } from '../../../shared/licensing'; -import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineIcon } from './assets/engine_icon'; import { MetaEngineIcon } from './assets/meta_engine_icon'; @@ -38,7 +39,7 @@ interface ISetEnginesCallbacks { } export const EngineOverview: React.FC = () => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); const { license } = useContext(LicenseContext) as ILicenseContext; const [isLoading, setIsLoading] = useState(true); @@ -94,10 +95,9 @@ export const EngineOverview: React.FC = () => {

- + {i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.engines', { + defaultMessage: 'Engines', + })}

@@ -119,10 +119,9 @@ export const EngineOverview: React.FC = () => {

- + {i18n.translate('xpack.enterpriseSearch.appSearch.enginesOverview.metaEngines', { + defaultMessage: 'Meta Engines', + })}

diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx index 46b6e61e352de5..8e92f21f8ffeda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.test.tsx @@ -4,10 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../__mocks__/kea.mock'; +import '../../../__mocks__/shallow_usecontext.mock'; +import { mockHttpValues } from '../../../__mocks__/'; + import React from 'react'; +import { mount } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; import { EuiBasicTable, EuiPagination, EuiButtonEmpty, EuiLink } from '@elastic/eui'; -import { mountWithContext } from '../../../__mocks__'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); import { sendTelemetry } from '../../../shared/telemetry'; @@ -16,22 +21,24 @@ import { EngineTable } from './engine_table'; describe('EngineTable', () => { const onPaginate = jest.fn(); // onPaginate updates the engines API call upstream - const wrapper = mountWithContext( - + const wrapper = mount( + + + ); const table = wrapper.find(EuiBasicTable); @@ -56,7 +63,7 @@ describe('EngineTable', () => { link.simulate('click'); expect(sendTelemetry).toHaveBeenCalledWith({ - http: expect.any(Object), + http: mockHttpValues.http, product: 'app_search', action: 'clicked', metric: 'engine_table_link', @@ -71,10 +78,16 @@ describe('EngineTable', () => { }); it('handles empty data', () => { - const emptyWrapper = mountWithContext( - {} }} /> + const emptyWrapper = mount( + + {} }} + /> + ); const emptyTable = emptyWrapper.find(EuiBasicTable); + expect(emptyTable.prop('pagination').pageIndex).toEqual(0); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx index 9c6122c88c7d7b..6888be1dc2b5b7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_table.tsx @@ -5,11 +5,13 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; import { FormattedMessage, FormattedDate, FormattedNumber } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; import { getEngineRoute } from '../../routes'; @@ -40,9 +42,9 @@ export const EngineTable: React.FC = ({ data, pagination: { totalEngines, pageIndex, onPaginate }, }) => { + const { http } = useValues(HttpLogic); const { externalUrl: { getAppSearchUrl }, - http, } = useContext(KibanaContext) as IKibanaContext; const engineLinkProps = (name: string) => ({ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx index a76b654ccddd06..f651511e61b440 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.test.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../__mocks__/kea.mock'; + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx index 334ca126cabb9d..833a782a32f008 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search/components/product_card/product_card.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; +import React from 'react'; +import { useValues } from 'kea'; import upperFirst from 'lodash/upperFirst'; import snakeCase from 'lodash/snakeCase'; import { i18n } from '@kbn/i18n'; @@ -12,7 +13,7 @@ import { EuiCard, EuiTextColor } from '@elastic/eui'; import { EuiButton } from '../../../shared/react_router_helpers'; import { sendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; +import { HttpLogic } from '../../../shared/http'; import './product_card.scss'; @@ -28,7 +29,7 @@ interface IProductCard { } export const ProductCard: React.FC = ({ product, image }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); return ( - - @@ -86,6 +80,8 @@ export const renderApp = ( ); return () => { ReactDOM.unmountComponentAtNode(params.element); + unmountHttpLogic(); + unmountFlashMessagesLogic(); }; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts index 136912847baa98..c12011b47a472b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.test.ts @@ -6,23 +6,25 @@ import { resetContext } from 'kea'; -import { FlashMessagesLogic, IFlashMessage } from './flash_messages_logic'; +import { mockHistory } from '../../__mocks__'; + +import { FlashMessagesLogic, mountFlashMessagesLogic, IFlashMessage } from './'; describe('FlashMessagesLogic', () => { - const DEFAULT_VALUES = { - messages: [], - queuedMessages: [], - historyListener: null, - }; + const mount = () => mountFlashMessagesLogic({ history: mockHistory as any }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); - it('has expected default values', () => { - FlashMessagesLogic.mount(); - expect(FlashMessagesLogic.values).toEqual(DEFAULT_VALUES); + it('has default values', () => { + mount(); + expect(FlashMessagesLogic.values).toEqual({ + messages: [], + queuedMessages: [], + historyListener: expect.any(Function), + }); }); describe('setFlashMessages()', () => { @@ -33,7 +35,7 @@ describe('FlashMessagesLogic', () => { { type: 'info', message: 'Everything is fine, nothing is ruined' }, ]; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages(messages); expect(FlashMessagesLogic.values.messages).toEqual(messages); @@ -42,7 +44,7 @@ describe('FlashMessagesLogic', () => { it('automatically converts to an array if a single message obj is passed in', () => { const message = { type: 'success', message: 'I turn into an array!' } as IFlashMessage; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages(message); expect(FlashMessagesLogic.values.messages).toEqual([message]); @@ -51,7 +53,7 @@ describe('FlashMessagesLogic', () => { describe('clearFlashMessages()', () => { it('sets messages back to an empty array', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setFlashMessages('test' as any); FlashMessagesLogic.actions.clearFlashMessages(); @@ -63,7 +65,7 @@ describe('FlashMessagesLogic', () => { it('sets an array of messages', () => { const queuedMessage: IFlashMessage = { type: 'error', message: 'You deleted a thing' }; - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setQueuedMessages(queuedMessage); expect(FlashMessagesLogic.values.queuedMessages).toEqual([queuedMessage]); @@ -72,7 +74,7 @@ describe('FlashMessagesLogic', () => { describe('clearQueuedMessages()', () => { it('sets queued messages back to an empty array', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setQueuedMessages('test' as any); FlashMessagesLogic.actions.clearQueuedMessages(); @@ -83,30 +85,25 @@ describe('FlashMessagesLogic', () => { describe('history listener logic', () => { describe('setHistoryListener()', () => { it('sets the historyListener value', () => { - FlashMessagesLogic.mount(); + mount(); FlashMessagesLogic.actions.setHistoryListener('test' as any); expect(FlashMessagesLogic.values.historyListener).toEqual('test'); }); }); - describe('listenToHistory()', () => { + describe('on mount', () => { it('listens for history changes and clears messages on change', () => { - FlashMessagesLogic.mount(); + mount(); + expect(mockHistory.listen).toHaveBeenCalled(); + FlashMessagesLogic.actions.setQueuedMessages(['queuedMessages'] as any); jest.spyOn(FlashMessagesLogic.actions, 'clearFlashMessages'); jest.spyOn(FlashMessagesLogic.actions, 'setFlashMessages'); jest.spyOn(FlashMessagesLogic.actions, 'clearQueuedMessages'); jest.spyOn(FlashMessagesLogic.actions, 'setHistoryListener'); - const mockListener = jest.fn(() => jest.fn()); - const history = { listen: mockListener } as any; - FlashMessagesLogic.actions.listenToHistory(history); - - expect(mockListener).toHaveBeenCalled(); - expect(FlashMessagesLogic.actions.setHistoryListener).toHaveBeenCalled(); - - const mockHistoryChange = (mockListener.mock.calls[0] as any)[0]; + const mockHistoryChange = (mockHistory.listen.mock.calls[0] as any)[0]; mockHistoryChange(); expect(FlashMessagesLogic.actions.clearFlashMessages).toHaveBeenCalled(); expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([ @@ -116,19 +113,20 @@ describe('FlashMessagesLogic', () => { }); }); - describe('beforeUnmount', () => { - it('removes history listener on unmount', () => { + describe('on unmount', () => { + it('removes history listener', () => { const mockUnlistener = jest.fn(); - const unmount = FlashMessagesLogic.mount(); + mockHistory.listen.mockReturnValueOnce(mockUnlistener); - FlashMessagesLogic.actions.setHistoryListener(mockUnlistener); + const unmount = mount(); unmount(); expect(mockUnlistener).toHaveBeenCalled(); }); it('does not crash if no listener exists', () => { - const unmount = FlashMessagesLogic.mount(); + const unmount = mount(); + FlashMessagesLogic.actions.setHistoryListener(null as any); unmount(); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts index 37a8f16acad6df..1735cc8ac72282 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_logic.ts @@ -24,7 +24,6 @@ export interface IFlashMessagesActions { clearFlashMessages(): void; setQueuedMessages(messages: IFlashMessage | IFlashMessage[]): { messages: IFlashMessage[] }; clearQueuedMessages(): void; - listenToHistory(history: History): History; setHistoryListener(historyListener: Function): { historyListener: Function }; } @@ -38,7 +37,6 @@ export const FlashMessagesLogic = kea null, setQueuedMessages: (messages) => ({ messages: convertToArray(messages) }), clearQueuedMessages: () => null, - listenToHistory: (history) => history, setHistoryListener: (historyListener) => ({ historyListener }), }, reducers: { @@ -63,21 +61,31 @@ export const FlashMessagesLogic = kea ({ - listenToHistory: (history) => { + events: ({ props, values, actions }) => ({ + afterMount: () => { // On React Router navigation, clear previous flash messages and load any queued messages - const unlisten = history.listen(() => { + const unlisten = props.history.listen(() => { actions.clearFlashMessages(); actions.setFlashMessages(values.queuedMessages); actions.clearQueuedMessages(); }); actions.setHistoryListener(unlisten); }, - }), - events: ({ values }) => ({ beforeUnmount: () => { const { historyListener: removeHistoryListener } = values; if (removeHistoryListener) removeHistoryListener(); }, }), }); + +/** + * Mount/props helper + */ +interface IFlashMessagesLogicProps { + history: History; +} +export const mountFlashMessagesLogic = (props: IFlashMessagesLogicProps) => { + FlashMessagesLogic(props); + const unmount = FlashMessagesLogic.mount(); + return unmount; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx deleted file mode 100644 index bcd7abd6d7ce23..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.test.tsx +++ /dev/null @@ -1,46 +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 '../../__mocks__/shallow_usecontext.mock'; -import '../../__mocks__/kea.mock'; - -import React from 'react'; -import { shallow } from 'enzyme'; -import { useValues, useActions } from 'kea'; - -import { mockHistory } from '../../__mocks__'; - -import { FlashMessagesProvider } from './'; - -describe('FlashMessagesProvider', () => { - const props = { history: mockHistory as any }; - const listenToHistory = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - (useActions as jest.Mock).mockImplementationOnce(() => ({ listenToHistory })); - }); - - it('does not render', () => { - const wrapper = shallow(); - - expect(wrapper.isEmptyRender()).toBe(true); - }); - - it('listens to history on mount', () => { - shallow(); - - expect(listenToHistory).toHaveBeenCalledWith(mockHistory); - }); - - it('does not add another history listener if one already exists', () => { - (useValues as jest.Mock).mockImplementationOnce(() => ({ historyListener: 'exists' as any })); - - shallow(); - - expect(listenToHistory).not.toHaveBeenCalledWith(props); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx deleted file mode 100644 index a3ceabcf6ac8a4..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/flash_messages_provider.tsx +++ /dev/null @@ -1,26 +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, { useEffect } from 'react'; -import { useValues, useActions } from 'kea'; -import { History } from 'history'; - -import { FlashMessagesLogic } from './flash_messages_logic'; - -interface IFlashMessagesProviderProps { - history: History; -} - -export const FlashMessagesProvider: React.FC = ({ history }) => { - const { historyListener } = useValues(FlashMessagesLogic); - const { listenToHistory } = useActions(FlashMessagesLogic); - - useEffect(() => { - if (!historyListener) listenToHistory(history); - }, []); - - return null; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts index c4daeb44420c86..21c1a60efa6b73 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/index.ts @@ -10,7 +10,7 @@ export { IFlashMessage, IFlashMessagesValues, IFlashMessagesActions, + mountFlashMessagesLogic, } from './flash_messages_logic'; -export { FlashMessagesProvider } from './flash_messages_provider'; export { flashAPIErrors } from './handle_api_errors'; export { setSuccessMessage, setErrorMessage, setQueuedSuccessMessage } from './set_message_helpers'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts index c3c60d77f4577b..f2ddd560ac9c1f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/set_message_helpers.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mockHistory } from '../../__mocks__'; + import { FlashMessagesLogic, + mountFlashMessagesLogic, setSuccessMessage, setErrorMessage, setQueuedSuccessMessage, @@ -15,7 +18,7 @@ describe('Flash Message Helpers', () => { const message = 'I am a message'; beforeEach(() => { - FlashMessagesLogic.mount(); + mountFlashMessagesLogic({ history: mockHistory as any }); }); it('setSuccessMessage()', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts index b65499be2f7c03..df32b5496c367d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.test.ts @@ -8,31 +8,20 @@ import { resetContext } from 'kea'; import { httpServiceMock } from 'src/core/public/mocks'; -import { HttpLogic } from './http_logic'; +import { HttpLogic, mountHttpLogic } from './http_logic'; describe('HttpLogic', () => { const mockHttp = httpServiceMock.createSetupContract(); - const DEFAULT_VALUES = { - http: null, - httpInterceptors: [], - errorConnecting: false, - readOnlyMode: false, - }; + const mount = () => mountHttpLogic({ http: mockHttp }); beforeEach(() => { jest.clearAllMocks(); resetContext({}); }); - it('has expected default values', () => { - HttpLogic.mount(); - expect(HttpLogic.values).toEqual(DEFAULT_VALUES); - }); - - describe('initializeHttp()', () => { - it('sets values based on passed props', () => { - HttpLogic.mount(); - HttpLogic.actions.initializeHttp({ + describe('mounts', () => { + it('sets values from props', () => { + mountHttpLogic({ http: mockHttp, errorConnecting: true, readOnlyMode: true, @@ -40,7 +29,7 @@ describe('HttpLogic', () => { expect(HttpLogic.values).toEqual({ http: mockHttp, - httpInterceptors: [], + httpInterceptors: expect.any(Array), errorConnecting: true, readOnlyMode: true, }); @@ -49,7 +38,9 @@ describe('HttpLogic', () => { describe('setErrorConnecting()', () => { it('sets errorConnecting value', () => { - HttpLogic.mount(); + mount(); + expect(HttpLogic.values.errorConnecting).toEqual(false); + HttpLogic.actions.setErrorConnecting(true); expect(HttpLogic.values.errorConnecting).toEqual(true); @@ -60,7 +51,9 @@ describe('HttpLogic', () => { describe('setReadOnlyMode()', () => { it('sets readOnlyMode value', () => { - HttpLogic.mount(); + mount(); + expect(HttpLogic.values.readOnlyMode).toEqual(false); + HttpLogic.actions.setReadOnlyMode(true); expect(HttpLogic.values.readOnlyMode).toEqual(true); @@ -72,10 +65,8 @@ describe('HttpLogic', () => { describe('http interceptors', () => { describe('initializeHttpInterceptors()', () => { beforeEach(() => { - HttpLogic.mount(); + mount(); jest.spyOn(HttpLogic.actions, 'setHttpInterceptors'); - HttpLogic.actions.initializeHttp({ http: mockHttp }); - HttpLogic.actions.initializeHttpInterceptors(); }); it('calls http.intercept and sets an array of interceptors', () => { @@ -165,7 +156,7 @@ describe('HttpLogic', () => { }); it('sets httpInterceptors and calls all valid remove functions on unmount', () => { - const unmount = HttpLogic.mount(); + const unmount = mount(); const httpInterceptors = [jest.fn(), undefined, jest.fn()] as any; HttpLogic.actions.setHttpInterceptors(httpInterceptors); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts index 72380142fe3998..d16e507bfb3bc4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_logic.ts @@ -7,7 +7,6 @@ import { kea, MakeLogicType } from 'kea'; import { HttpSetup, HttpInterceptorResponseError, HttpResponse } from 'src/core/public'; -import { IHttpProviderProps } from './http_provider'; import { READ_ONLY_MODE_HEADER } from '../../../../common/constants'; @@ -18,7 +17,6 @@ export interface IHttpValues { readOnlyMode: boolean; } export interface IHttpActions { - initializeHttp({ http, errorConnecting, readOnlyMode }: IHttpProviderProps): IHttpProviderProps; initializeHttpInterceptors(): void; setHttpInterceptors(httpInterceptors: Function[]): { httpInterceptors: Function[] }; setErrorConnecting(errorConnecting: boolean): { errorConnecting: boolean }; @@ -28,19 +26,13 @@ export interface IHttpActions { export const HttpLogic = kea>({ path: ['enterprise_search', 'http_logic'], actions: { - initializeHttp: (props) => props, initializeHttpInterceptors: () => null, setHttpInterceptors: (httpInterceptors) => ({ httpInterceptors }), setErrorConnecting: (errorConnecting) => ({ errorConnecting }), setReadOnlyMode: (readOnlyMode) => ({ readOnlyMode }), }, - reducers: { - http: [ - (null as unknown) as HttpSetup, - { - initializeHttp: (_, { http }) => http, - }, - ], + reducers: ({ props }) => ({ + http: [props.http, {}], httpInterceptors: [ [], { @@ -48,20 +40,18 @@ export const HttpLogic = kea>({ }, ], errorConnecting: [ - false, + props.errorConnecting || false, { - initializeHttp: (_, { errorConnecting }) => !!errorConnecting, setErrorConnecting: (_, { errorConnecting }) => errorConnecting, }, ], readOnlyMode: [ - false, + props.readOnlyMode || false, { - initializeHttp: (_, { readOnlyMode }) => !!readOnlyMode, setReadOnlyMode: (_, { readOnlyMode }) => readOnlyMode, }, ], - }, + }), listeners: ({ values, actions }) => ({ initializeHttpInterceptors: () => { const httpInterceptors = []; @@ -103,7 +93,10 @@ export const HttpLogic = kea>({ actions.setHttpInterceptors(httpInterceptors); }, }), - events: ({ values }) => ({ + events: ({ values, actions }) => ({ + afterMount: () => { + actions.initializeHttpInterceptors(); + }, beforeUnmount: () => { values.httpInterceptors.forEach((removeInterceptorFn?: Function) => { if (removeInterceptorFn) removeInterceptorFn(); @@ -112,6 +105,20 @@ export const HttpLogic = kea>({ }), }); +/** + * Mount/props helper + */ +interface IHttpLogicProps { + http: HttpSetup; + errorConnecting?: boolean; + readOnlyMode?: boolean; +} +export const mountHttpLogic = (props: IHttpLogicProps) => { + HttpLogic(props); + const unmount = HttpLogic.mount(); + return unmount; +}; + /** * Small helper that checks whether or not an http call is for an Enterprise Search API */ diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx deleted file mode 100644 index 902c910f10d7c3..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.test.tsx +++ /dev/null @@ -1,45 +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 '../../__mocks__/shallow_usecontext.mock'; -import '../../__mocks__/kea.mock'; - -import React from 'react'; -import { shallow } from 'enzyme'; -import { useActions } from 'kea'; - -import { HttpProvider } from './'; - -describe('HttpProvider', () => { - const props = { - http: {} as any, - errorConnecting: false, - readOnlyMode: false, - }; - const initializeHttp = jest.fn(); - const initializeHttpInterceptors = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - (useActions as jest.Mock).mockImplementationOnce(() => ({ - initializeHttp, - initializeHttpInterceptors, - })); - }); - - it('does not render', () => { - const wrapper = shallow(); - - expect(wrapper.isEmptyRender()).toBe(true); - }); - - it('calls initialization actions on mount', () => { - shallow(); - - expect(initializeHttp).toHaveBeenCalledWith(props); - expect(initializeHttpInterceptors).toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx deleted file mode 100644 index db1b0d611079a4..00000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/http_provider.tsx +++ /dev/null @@ -1,29 +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, { useEffect } from 'react'; -import { useActions } from 'kea'; - -import { HttpSetup } from 'src/core/public'; - -import { HttpLogic } from './http_logic'; - -export interface IHttpProviderProps { - http: HttpSetup; - errorConnecting?: boolean; - readOnlyMode?: boolean; -} - -export const HttpProvider: React.FC = (props) => { - const { initializeHttp, initializeHttpInterceptors } = useActions(HttpLogic); - - useEffect(() => { - initializeHttp(props); - initializeHttpInterceptors(); - }, []); - - return null; -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts index db65e80ca25c2e..46a52415f85643 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/http/index.ts @@ -4,5 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { HttpLogic, IHttpValues, IHttpActions } from './http_logic'; -export { HttpProvider } from './http_provider'; +export { HttpLogic, IHttpValues, IHttpActions, mountHttpLogic } from './http_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 1d64b453b2c2c3..073c548ba47fa2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../__mocks__/kea.mock'; +import '../../__mocks__/shallow_usecontext.mock'; +import { mockHttpValues } from '../../__mocks__'; + import React from 'react'; +import { shallow } from 'enzyme'; -import { httpServiceMock } from 'src/core/public/mocks'; import { JSON_HEADER as headers } from '../../../../common/constants'; -import { mountWithKibanaContext } from '../../__mocks__'; import { sendTelemetry, @@ -18,8 +21,6 @@ import { } from './'; describe('Shared Telemetry Helpers', () => { - const httpMock = httpServiceMock.createSetupContract(); - beforeEach(() => { jest.clearAllMocks(); }); @@ -27,13 +28,13 @@ describe('Shared Telemetry Helpers', () => { describe('sendTelemetry', () => { it('successfully calls the server-side telemetry endpoint', () => { sendTelemetry({ - http: httpMock, + http: mockHttpValues.http, product: 'enterprise_search', action: 'viewed', metric: 'setup_guide', }); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); @@ -50,33 +51,27 @@ describe('Shared Telemetry Helpers', () => { describe('React component helpers', () => { it('SendEnterpriseSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"enterprise_search","action":"viewed","metric":"page"}', }); }); it('SendAppSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"app_search","action":"clicked","metric":"button"}', }); }); it('SendWorkplaceSearchTelemetry component', () => { - mountWithKibanaContext(, { - http: httpMock, - }); + shallow(); - expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { + expect(mockHttpValues.http.put).toHaveBeenCalledWith('/api/enterprise_search/stats', { headers, body: '{"product":"workplace_search","action":"error","metric":"not_found"}', }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index e3c9ba9b8a218f..2f87597897b41f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; +import { useValues } from 'kea'; import { HttpSetup } from 'src/core/public'; import { JSON_HEADER as headers } from '../../../../common/constants'; -import { KibanaContext, IKibanaContext } from '../../index'; +import { HttpLogic } from '../http'; interface ISendTelemetryProps { action: 'viewed' | 'error' | 'clicked'; @@ -41,7 +42,7 @@ export const SendEnterpriseSearchTelemetry: React.FC = ({ action, metric, }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'enterprise_search' }); @@ -51,7 +52,7 @@ export const SendEnterpriseSearchTelemetry: React.FC = ({ }; export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'app_search' }); @@ -61,7 +62,7 @@ export const SendAppSearchTelemetry: React.FC = ({ action, }; export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { - const { http } = useContext(KibanaContext) as IKibanaContext; + const { http } = useValues(HttpLogic); useEffect(() => { sendTelemetry({ http, action, metric, product: 'workplace_search' }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx index 429a2c509813db..c73eb05ccec16a 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../../__mocks__/kea.mock'; import '../../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx index a9140006541650..a80de9fd6ac82c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -5,17 +5,19 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { sendTelemetry } from '../../../../shared/telemetry'; +import { HttpLogic } from '../../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../../index'; export const ProductButton: React.FC = () => { + const { http } = useValues(HttpLogic); const { externalUrl: { getWorkplaceSearchUrl }, - http, } = useContext(KibanaContext) as IKibanaContext; const buttonProps = { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx index 1d7c565935e970..c890adb8ea0438 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../__mocks__/kea.mock'; import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx index 786357358dfa6d..79be7ef1cb1587 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_card.tsx @@ -5,6 +5,7 @@ */ import React, { useContext } from 'react'; +import { useValues } from 'kea'; import { EuiButton, @@ -17,7 +18,9 @@ import { EuiButtonEmptyProps, EuiLinkProps, } from '@elastic/eui'; + import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; interface IOnboardingCardProps { @@ -39,8 +42,8 @@ export const OnboardingCard: React.FC = ({ actionPath, complete, }) => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx index 0baadfc912ad55..079d981533e012 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/onboarding_steps.tsx @@ -23,6 +23,7 @@ import { } from '@elastic/eui'; import sharedSourcesIcon from '../../components/shared/assets/share_circle.svg'; import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; @@ -135,8 +136,8 @@ export const OnboardingSteps: React.FC = () => { }; export const OrgNameOnboarding: React.FC = () => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx index 0813999c9a0786..dd62e6de7c046d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/overview/recent_activity.tsx @@ -14,6 +14,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { ContentSection } from '../../components/shared/content_section'; import { sendTelemetry } from '../../../shared/telemetry'; +import { HttpLogic } from '../../../shared/http'; import { KibanaContext, IKibanaContext } from '../../../index'; import { SOURCE_DETAILS_PATH, getContentSourcePath } from '../../routes'; @@ -93,8 +94,8 @@ export const RecentActivityItem: React.FC = ({ timestamp, sourceId, }) => { + const { http } = useValues(HttpLogic); const { - http, externalUrl: { getWorkplaceSearchUrl }, } = useContext(KibanaContext) as IKibanaContext; From a537f9af500bc3d3a6e2ceea8817ee89c474cbb0 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Tue, 22 Sep 2020 12:33:59 -0700 Subject: [PATCH 15/15] [Reporting] Clean Up TypeScript Definitions (#76566) * [Reporting] Simplify Export Type Definitions, use defaults for generics, refactor * ReportApiJSON interface for common * rename JobSummary to JobStatusBucket for clarity * revert unneeded create mock changes * clean up the diff * revert changes to worker.js * rewrite comment * rename type to jobtype in JobStatusBucket * allow type inference * JobSummarySet * remove odd comment * Reflect that browser timezone may be undefined in the BaseParams * comment about optional browserTimezone * revert unecessary es archive change Co-authored-by: Elastic Machine --- x-pack/plugins/reporting/common/types.ts | 71 +++++----- .../components/buttons/report_info_button.tsx | 15 +- .../public/components/job_download_button.tsx | 3 +- .../public/components/job_failure.tsx | 4 +- .../public/components/job_success.tsx | 5 +- .../components/job_warning_formulas.tsx | 5 +- .../components/job_warning_max_size.tsx | 5 +- .../public/components/report_listing.tsx | 6 +- .../components/reporting_panel_content.tsx | 7 +- .../screen_capture_panel_content.tsx | 9 +- x-pack/plugins/reporting/public/index.ts | 21 +++ .../__snapshots__/stream_handler.test.ts.snap | 10 +- .../public/lib/reporting_api_client.ts | 50 ++----- .../public/lib/stream_handler.test.ts | 27 ++-- .../reporting/public/lib/stream_handler.ts | 21 +-- x-pack/plugins/reporting/public/plugin.tsx | 8 +- .../register_csv_reporting.tsx | 4 +- .../register_pdf_png_reporting.tsx | 6 +- .../chromium/driver/chromium_driver.ts | 4 +- .../browsers/chromium/driver_factory/index.ts | 2 +- .../common/decrypt_job_headers.test.ts | 29 ++-- .../common/decrypt_job_headers.ts | 25 +--- .../common/get_conditional_headers.test.ts | 21 +-- .../common/get_conditional_headers.ts | 15 +- .../export_types/common/get_full_urls.test.ts | 82 ++++------- .../export_types/common/get_full_urls.ts | 8 +- .../server/export_types/common/index.ts | 18 +++ .../common/omit_blocked_headers.test.ts | 17 +-- .../common/omit_blocked_headers.ts | 8 +- .../server/export_types/csv/create_job.ts | 9 +- .../server/export_types/csv/execute_job.ts | 2 +- .../export_types/csv/generate_csv/index.ts | 2 +- .../server/export_types/csv/index.ts | 6 +- .../server/export_types/csv/types.d.ts | 34 ++--- .../csv_from_savedobject/create_job.ts | 48 ++----- .../csv_from_savedobject/execute_job.ts | 23 +-- .../csv_from_savedobject/index.ts | 3 - .../lib/get_csv_job.test.ts | 2 +- .../csv_from_savedobject/lib/get_csv_job.ts | 8 +- .../lib/get_filters.test.ts | 2 +- .../csv_from_savedobject/lib/get_filters.ts | 2 +- .../csv_from_savedobject/types.d.ts | 12 +- .../export_types/png/create_job/index.ts | 5 +- .../export_types/png/execute_job/index.ts | 21 ++- .../server/export_types/png/index.ts | 2 - .../export_types/png/lib/generate_png.ts | 4 +- .../server/export_types/png/types.d.ts | 18 ++- .../printable_pdf/create_job/index.ts | 5 +- .../printable_pdf/execute_job/index.ts | 20 ++- .../export_types/printable_pdf/index.ts | 2 - .../printable_pdf/lib/generate_pdf.ts | 8 +- .../printable_pdf/lib/get_custom_logo.test.ts | 7 +- .../printable_pdf/lib/get_custom_logo.ts | 2 +- .../export_types/printable_pdf/types.d.ts | 21 ++- .../reporting/server/lib/check_license.ts | 8 +- .../reporting/server/lib/create_queue.ts | 6 +- .../reporting/server/lib/create_worker.ts | 18 +-- .../reporting/server/lib/enqueue_job.ts | 29 +++- .../server/lib/export_types_registry.ts | 51 +++---- .../server/lib/layouts/create_layout.ts | 5 +- .../reporting/server/lib/layouts/index.ts | 4 +- .../server/lib/layouts/preserve_layout.ts | 3 +- .../server/lib/layouts/print_layout.ts | 10 +- .../server/lib/screenshots/get_time_range.ts | 2 +- .../reporting/server/lib/screenshots/index.ts | 4 +- .../server/lib/screenshots/observable.test.ts | 2 +- .../server/lib/screenshots/open_url.ts | 5 +- .../reporting/server/lib/store/index.ts | 2 +- .../reporting/server/lib/store/report.test.ts | 100 ++++++------- .../reporting/server/lib/store/report.ts | 96 +++++++------ .../reporting/server/lib/store/store.test.ts | 133 +++++++++--------- .../reporting/server/lib/store/store.ts | 74 ++-------- .../reporting/server/lib/tasks/index.ts | 32 +++++ .../server/routes/diagnostic/browser.ts | 2 +- .../server/routes/diagnostic/config.ts | 2 +- .../server/routes/diagnostic/index.ts | 6 + .../server/routes/diagnostic/screenshot.ts | 7 +- .../generate_from_savedobject_immediate.ts | 24 ++-- .../server/routes/generation.test.ts | 20 ++- .../plugins/reporting/server/routes/index.ts | 7 + .../reporting/server/routes/jobs.test.ts | 5 +- .../plugins/reporting/server/routes/jobs.ts | 2 +- .../server/routes/lib/get_document_payload.ts | 33 +++-- .../routes/lib/get_job_params_from_request.ts | 6 +- .../reporting/server/routes/lib/jobs_query.ts | 5 +- .../reporting/server/routes/types.d.ts | 4 +- x-pack/plugins/reporting/server/types.ts | 110 +++------------ 87 files changed, 698 insertions(+), 893 deletions(-) create mode 100644 x-pack/plugins/reporting/server/lib/tasks/index.ts diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 18b0ac2a728026..24c126bfe0571f 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -7,7 +7,12 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { ReportingConfigType } from '../server/config'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -export { LayoutInstance } from '../server/lib/layouts'; +import { LayoutParams } from '../server/lib/layouts'; +export { LayoutParams }; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +export { ReportDocument, ReportSource } from '../server/lib/store/report'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +export { BaseParams } from '../server/types'; export type JobId = string; export type JobStatus = @@ -17,45 +22,43 @@ export type JobStatus = | 'processing' | 'failed'; -export interface SourceJob { - _id: JobId; - _source: { - status: JobStatus; - output: { - max_size_reached: boolean; - csv_contains_formulas: boolean; - }; - payload: { - type: string; - title: string; - }; - }; -} - export interface JobContent { content: string; } -export interface JobSummary { - id: JobId; - status: JobStatus; - title: string; - type: string; - maxSizeReached: boolean; - csvContainsFormulas: boolean; -} - -export interface JobStatusBuckets { - completed: JobSummary[]; - failed: JobSummary[]; +export interface ReportApiJSON { + id: string; + index: string; + kibana_name: string; + kibana_id: string; + browser_type: string | undefined; + created_at: string; + priority?: number; + jobtype: string; + created_by: string | false; + timeout?: number; + output?: { + content_type: string; + size: number; + warnings?: string[]; + }; + process_expiration?: string; + completed_at: string | undefined; + payload: { + layout?: LayoutParams; + title: string; + browserTimezone?: string; + }; + meta: { + layout?: string; + objectType: string; + }; + max_attempts: number; + started_at: string | undefined; + attempts: number; + status: string; } -type DownloadLink = string; -export type DownloadReportFn = (jobId: JobId) => DownloadLink; - -type ManagementLink = string; -export type ManagementLinkFn = () => ManagementLink; - export interface PollerOptions { functionToPoll: () => Promise; pollFrequencyInMillis: number; diff --git a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx index 941baa5af67765..068cb7d44b0a16 100644 --- a/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/components/buttons/report_info_button.tsx @@ -15,10 +15,11 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; +import React, { Component, Fragment } from 'react'; +import { ReportApiJSON } from '../../../common/types'; import { USES_HEADLESS_JOB_TYPES } from '../../../constants'; -import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client'; +import { ReportingAPIClient } from '../../lib/reporting_api_client'; interface Props { jobId: string; @@ -29,14 +30,14 @@ interface State { isLoading: boolean; isFlyoutVisible: boolean; calloutTitle: string; - info: JobInfo | null; + info: ReportApiJSON | null; error: Error | null; } const NA = 'n/a'; const UNKNOWN = 'unknown'; -const getDimensions = (info: JobInfo): string => { +const getDimensions = (info: ReportApiJSON): string => { const defaultDimensions = { width: null, height: null }; const { width, height } = get(info, 'payload.layout.dimensions', defaultDimensions); if (width && height) { @@ -121,10 +122,6 @@ export class ReportInfoButton extends Component { title: 'Title', description: get(info, 'payload.title') || NA, }, - { - title: 'Type', - description: get(info, 'payload.type') || NA, - }, { title: 'Layout', description: get(info, 'meta.layout') || NA, @@ -263,7 +260,7 @@ export class ReportInfoButton extends Component { private loadInfo = async () => { this.setState({ isLoading: true }); try { - const info: JobInfo = await this.props.apiClient.getInfo(this.props.jobId); + const info: ReportApiJSON = await this.props.apiClient.getInfo(this.props.jobId); if (this.mounted) { this.setState({ isLoading: false, info }); } diff --git a/x-pack/plugins/reporting/public/components/job_download_button.tsx b/x-pack/plugins/reporting/public/components/job_download_button.tsx index 7dff2cafa047b2..8cf3ce8644add1 100644 --- a/x-pack/plugins/reporting/public/components/job_download_button.tsx +++ b/x-pack/plugins/reporting/public/components/job_download_button.tsx @@ -7,7 +7,8 @@ import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { JobId, JobSummary } from '../../common/types'; +import { JobSummary } from '../'; +import { JobId } from '../../common/types'; interface Props { getUrl: (jobId: JobId) => string; diff --git a/x-pack/plugins/reporting/public/components/job_failure.tsx b/x-pack/plugins/reporting/public/components/job_failure.tsx index 0da67ea3674375..8d8f32f692343d 100644 --- a/x-pack/plugins/reporting/public/components/job_failure.tsx +++ b/x-pack/plugins/reporting/public/components/job_failure.tsx @@ -9,8 +9,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary, ManagementLinkFn } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobSummary, ManagementLinkFn } from '../../common/types'; export const getFailureToast = ( errorText: string, @@ -22,7 +22,7 @@ export const getFailureToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/job_success.tsx b/x-pack/plugins/reporting/public/components/job_success.tsx index 7f33321ee36454..05cf2c4c5784a6 100644 --- a/x-pack/plugins/reporting/public/components/job_success.tsx +++ b/x-pack/plugins/reporting/public/components/job_success.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getSuccessToast = ( ), color: 'success', diff --git a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx index e2afae1feaa01c..8cccc94e98dcda 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_formulas.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getWarningFormulasToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx index 6c0d6118dfff28..c350eef0e5a547 100644 --- a/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx +++ b/x-pack/plugins/reporting/public/components/job_warning_max_size.tsx @@ -7,8 +7,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { ToastInput } from 'src/core/public'; +import { JobSummary } from '../'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { JobId, JobSummary } from '../../common/types'; +import { JobId } from '../../common/types'; import { DownloadButton } from './job_download_button'; import { ReportLink } from './report_link'; @@ -21,7 +22,7 @@ export const getWarningMaxSizeToast = ( ), text: toMountPoint( diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index f326d365351f20..cea402d6a98f27 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -41,17 +41,17 @@ export interface Job { type: string; object_type: string; object_title: string; - created_by?: string; + created_by?: string | false; created_at: string; started_at?: string; completed_at?: string; status: string; statusLabel: string; - max_size_reached: boolean; + max_size_reached?: boolean; attempts: number; max_attempts: number; csv_contains_formulas: boolean; - warnings: string[]; + warnings?: string[]; } export interface Props { diff --git a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx index eddf151167be8d..22b97f45db186d 100644 --- a/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/reporting_panel_content.tsx @@ -7,10 +7,11 @@ import { EuiButton, EuiCopy, EuiForm, EuiFormRow, EuiSpacer, EuiText } from '@elastic/eui'; import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component, ReactElement } from 'react'; -import url from 'url'; import { ToastsSetup } from 'src/core/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; +import url from 'url'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; +import { BaseParams } from '../../common/types'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface Props { apiClient: ReportingAPIClient; @@ -19,7 +20,7 @@ interface Props { layoutId: string | undefined; objectId?: string; objectType: string; - getJobParams: () => any; + getJobParams: () => BaseParams; options?: ReactElement; isDirty: boolean; onClose: () => void; diff --git a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx index 9fb74a70ff1ac0..4a62ab2b76508b 100644 --- a/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx +++ b/x-pack/plugins/reporting/public/components/screen_capture_panel_content.tsx @@ -4,12 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component, Fragment } from 'react'; import { ToastsSetup } from 'src/core/public'; -import { ReportingPanelContent } from './reporting_panel_content'; +import { BaseParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { ReportingPanelContent } from './reporting_panel_content'; interface Props { apiClient: ReportingAPIClient; @@ -17,7 +18,7 @@ interface Props { reportType: string; objectId?: string; objectType: string; - getJobParams: () => any; + getJobParams: () => BaseParams; isDirty: boolean; onClose: () => void; } @@ -83,7 +84,7 @@ export class ScreenCapturePanelContent extends Component { ); }; - private handlePrintLayoutChange = (evt: any) => { + private handlePrintLayoutChange = (evt: EuiSwitchEvent) => { this.setState({ usePrintLayout: evt.target.checked }); }; diff --git a/x-pack/plugins/reporting/public/index.ts b/x-pack/plugins/reporting/public/index.ts index 185367a85bdc0b..251fd14ee4d57c 100644 --- a/x-pack/plugins/reporting/public/index.ts +++ b/x-pack/plugins/reporting/public/index.ts @@ -7,6 +7,7 @@ import { PluginInitializerContext } from 'src/core/public'; import { ReportingPublicPlugin } from './plugin'; import * as jobCompletionNotifications from './lib/job_completion_notifications'; +import { JobId, JobStatus } from '../common/types'; export function plugin(initializerContext: PluginInitializerContext) { return new ReportingPublicPlugin(initializerContext); @@ -14,3 +15,23 @@ export function plugin(initializerContext: PluginInitializerContext) { export { ReportingPublicPlugin as Plugin }; export { jobCompletionNotifications }; + +export interface JobSummary { + id: JobId; + status: JobStatus; + title: string; + jobtype: string; + maxSizeReached?: boolean; + csvContainsFormulas?: boolean; +} + +export interface JobSummarySet { + completed: JobSummary[]; + failed: JobSummary[]; +} + +type DownloadLink = string; +export type DownloadReportFn = (jobId: JobId) => DownloadLink; + +type ManagementLink = string; +export type ManagementLinkFn = () => ManagementLink; diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 6b95a00ea00098..f1d9d747a72362 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -6,20 +6,20 @@ Object { Object { "csvContainsFormulas": false, "id": "job-source-mock1", + "jobtype": undefined, "maxSizeReached": false, "status": "completed", "title": "specimen", - "type": "spectacular", }, ], "failed": Array [ Object { "csvContainsFormulas": false, "id": "job-source-mock2", + "jobtype": undefined, "maxSizeReached": false, "status": "failed", "title": "specimen", - "type": "spectacular", }, ], } @@ -49,9 +49,9 @@ Array [ Object { "csvContainsFormulas": true, "id": "yas3", + "jobtype": "yas", "status": "completed", "title": "Yas", - "type": "yas", } } /> @@ -149,10 +149,10 @@ Array [ job={ Object { "id": "yas2", + "jobtype": "yas", "maxSizeReached": true, "status": "completed", "title": "Yas", - "type": "yas", } } /> @@ -191,9 +191,9 @@ Array [ job={ Object { "id": "yas1", + "jobtype": "yas", "status": "completed", "title": "Yas", - "type": "yas", } } /> diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts index 2f813bd811c6cd..2853caaaaa1b51 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client.ts @@ -7,10 +7,11 @@ import { stringify } from 'query-string'; import rison from 'rison-node'; import { HttpSetup } from 'src/core/public'; -import { JobId, SourceJob } from '../../common/types'; +import { DownloadReportFn, ManagementLinkFn } from '../'; +import { JobId, ReportApiJSON, ReportDocument, ReportSource } from '../../common/types'; import { - API_BASE_URL, API_BASE_GENERATE, + API_BASE_URL, API_LIST_URL, REPORTING_MANAGEMENT_HOME, } from '../../constants'; @@ -18,7 +19,7 @@ import { add } from './job_completion_notifications'; export interface JobQueueEntry { _id: string; - _source: any; + _source: ReportSource; } export interface JobContent { @@ -26,40 +27,6 @@ export interface JobContent { content_type: boolean; } -export interface JobInfo { - kibana_name: string; - kibana_id: string; - browser_type: string; - created_at: string; - priority: number; - jobtype: string; - created_by: string; - timeout: number; - output: { - content_type: string; - size: number; - warnings: string[]; - }; - process_expiration: string; - completed_at: string; - payload: { - layout: { id: string; dimensions: { width: number; height: number } }; - objects: Array<{ relativeUrl: string }>; - type: string; - title: string; - forceNow: string; - browserTimezone: string; - }; - meta: { - layout: string; - objectType: string; - }; - max_attempts: number; - started_at: string; - attempts: number; - status: string; -} - interface JobParams { [paramName: string]: any; } @@ -121,13 +88,13 @@ export class ReportingAPIClient { }); } - public getInfo(jobId: string): Promise { + public getInfo(jobId: string): Promise { return this.http.get(`${API_LIST_URL}/info/${jobId}`, { asSystemRequest: true, }); } - public findForJobIds = (jobIds: JobId[]): Promise => { + public findForJobIds = (jobIds: JobId[]): Promise => { return this.http.fetch(`${API_LIST_URL}/list`, { query: { page: 0, ids: jobIds.join(',') }, method: 'GET', @@ -159,9 +126,10 @@ export class ReportingAPIClient { return resp; }; - public getManagementLink = () => this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); + public getManagementLink: ManagementLinkFn = () => + this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); - public getDownloadLink = (jobId: JobId) => + public getDownloadLink: DownloadReportFn = (jobId: JobId) => this.http.basePath.prepend(`${API_LIST_URL}/download/${jobId}`); /* diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 998f0711b13553..f91517e4397f93 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -6,7 +6,8 @@ import sinon, { stub } from 'sinon'; import { NotificationsStart } from 'src/core/public'; -import { JobSummary, SourceJob } from '../../common/types'; +import { JobSummary } from '../'; +import { ReportDocument } from '../../common/types'; import { ReportingAPIClient } from './reporting_api_client'; import { ReportingNotifierStreamHandler } from './stream_handler'; @@ -23,7 +24,7 @@ const mockJobsFound = [ _source: { status: 'completed', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, { @@ -31,7 +32,7 @@ const mockJobsFound = [ _source: { status: 'failed', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, { @@ -39,14 +40,14 @@ const mockJobsFound = [ _source: { status: 'pending', output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { type: 'spectacular', title: 'specimen' }, + payload: { title: 'specimen' }, }, }, ]; const jobQueueClientMock: ReportingAPIClient = { findForJobIds: async (jobIds: string[]) => { - return mockJobsFound as SourceJob[]; + return mockJobsFound as ReportDocument[]; }, getContent: (): Promise => { return Promise.resolve({ content: 'this is the completed report data' }); @@ -109,7 +110,7 @@ describe('stream handler', () => { { id: 'yas1', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', } as JobSummary, ], @@ -130,7 +131,7 @@ describe('stream handler', () => { { id: 'yas2', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', maxSizeReached: true, } as JobSummary, @@ -152,7 +153,7 @@ describe('stream handler', () => { { id: 'yas3', title: 'Yas', - type: 'yas', + jobtype: 'yas', status: 'completed', csvContainsFormulas: true, } as JobSummary, @@ -175,7 +176,7 @@ describe('stream handler', () => { { id: 'yas7', title: 'Yas 7', - type: 'yas', + jobtype: 'yas', status: 'failed', } as JobSummary, ], @@ -195,20 +196,20 @@ describe('stream handler', () => { { id: 'yas8', title: 'Yas 8', - type: 'yas', + jobtype: 'yas', status: 'completed', } as JobSummary, { id: 'yas9', title: 'Yas 9', - type: 'yas', + jobtype: 'yas', status: 'completed', csvContainsFormulas: true, } as JobSummary, { id: 'yas10', title: 'Yas 10', - type: 'yas', + jobtype: 'yas', status: 'completed', maxSizeReached: true, } as JobSummary, @@ -217,7 +218,7 @@ describe('stream handler', () => { { id: 'yas13', title: 'Yas 13', - type: 'yas', + jobtype: 'yas', status: 'failed', } as JobSummary, ], diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 80ba02e17d56d3..d97c0a7a2d11ef 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -8,7 +8,8 @@ import { i18n } from '@kbn/i18n'; import * as Rx from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { NotificationsSetup } from 'src/core/public'; -import { JobId, JobStatusBuckets, JobSummary, SourceJob } from '../../common/types'; +import { JobSummarySet, JobSummary } from '../'; +import { JobId, ReportDocument } from '../../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JOB_STATUS_COMPLETED, @@ -28,14 +29,14 @@ function updateStored(jobIds: JobId[]): void { sessionStorage.setItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JSON.stringify(jobIds)); } -function summarizeJob(src: SourceJob): JobSummary { +function getReportStatus(src: ReportDocument): JobSummary { return { id: src._id, status: src._source.status, title: src._source.payload.title, - type: src._source.payload.type, - maxSizeReached: src._source.output.max_size_reached, - csvContainsFormulas: src._source.output.csv_contains_formulas, + jobtype: src._source.jobtype, + maxSizeReached: src._source.output?.max_size_reached, + csvContainsFormulas: src._source.output?.csv_contains_formulas, }; } @@ -48,7 +49,7 @@ export class ReportingNotifierStreamHandler { public showNotifications({ completed: completedJobs, failed: failedJobs, - }: JobStatusBuckets): Rx.Observable { + }: JobSummarySet): Rx.Observable { const showNotificationsAsync = async () => { // notifications with download link for (const job of completedJobs) { @@ -92,9 +93,9 @@ export class ReportingNotifierStreamHandler { * An observable that finds jobs that are known to be "processing" (stored in * session storage) but have non-processing job status on the server */ - public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable { + public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable { return Rx.from(this.apiClient.findForJobIds(storedJobs)).pipe( - map((jobs: SourceJob[]) => { + map((jobs: ReportDocument[]) => { const completedJobs: JobSummary[] = []; const failedJobs: JobSummary[] = []; const pending: JobId[] = []; @@ -107,9 +108,9 @@ export class ReportingNotifierStreamHandler { } = job; if (storedJobs.includes(jobId)) { if (jobStatus === JOB_STATUS_COMPLETED || jobStatus === JOB_STATUS_WARNINGS) { - completedJobs.push(summarizeJob(job)); + completedJobs.push(getReportStatus(job)); } else if (jobStatus === JOB_STATUS_FAILED) { - failedJobs.push(summarizeJob(job)); + failedJobs.push(getReportStatus(job)); } else { pending.push(jobId); } diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index a134377e194b8b..cc5964f7379888 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -27,8 +27,9 @@ import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { durationToNumber } from '../common/schema_utils'; -import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; +import { JobId, ReportingConfigType } from '../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants'; +import { JobSummarySet } from './'; import { getGeneralErrorToast } from './components'; import { ReportListing } from './components/report_listing'; import { ReportingAPIClient } from './lib/reporting_api_client'; @@ -46,10 +47,7 @@ function getStored(): JobId[] { return sessionValue ? JSON.parse(sessionValue) : []; } -function handleError( - notifications: NotificationsSetup, - err: Error -): Rx.Observable { +function handleError(notifications: NotificationsSetup, err: Error): Rx.Observable { notifications.toasts.addDanger( getGeneralErrorToast( i18n.translate('xpack.reporting.publicNotifier.pollingErrorMessage', { diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index 4ad35fd7688254..451d907199c4c0 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { JobParamsDiscoverCsv, SearchRequest } from '../../server/export_types/csv/types'; +import { JobParamsCSV, SearchRequest } from '../../server/export_types/csv/types'; import { ReportingPanelContent } from '../components/reporting_panel_content'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -59,7 +59,7 @@ export const csvReportingProvider = ({ return []; } - const jobParams: JobParamsDiscoverCsv = { + const jobParams: JobParamsCSV = { browserTimezone, objectType, title: sharingData.title as string, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index e10d04ea5fc6bd..2dab66187bb25d 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -10,7 +10,7 @@ import React from 'react'; import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../../licensing/public'; -import { LayoutInstance } from '../../common/types'; +import { LayoutParams } from '../../common/types'; import { JobParamsPNG } from '../../server/export_types/png/types'; import { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; @@ -80,7 +80,7 @@ export const reportingPDFPNGProvider = ({ objectType, browserTimezone, relativeUrls: [relativeUrl], // multi URL for PDF - layout: sharingData.layout as LayoutInstance, + layout: sharingData.layout as LayoutParams, title: sharingData.title as string, }; }; @@ -96,7 +96,7 @@ export const reportingPDFPNGProvider = ({ objectType, browserTimezone, relativeUrl, // single URL for PNG - layout: sharingData.layout as LayoutInstance, + layout: sharingData.layout as LayoutParams, title: sharingData.title as string, }; }; diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index 0a76c7fcfd3b27..04ab572a53dbc7 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -10,10 +10,10 @@ import open from 'opn'; import { ElementHandle, EvaluateFn, Page, Response, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; import { getDisallowedOutgoingUrlError } from '../'; +import { ConditionalHeaders, ConditionalHeadersConditions } from '../../../export_types/common'; import { LevelLogger } from '../../../lib'; import { ViewZoomWidthHeight } from '../../../lib/layouts/layout'; import { ElementPosition } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; import { allowRequest, NetworkPolicy } from '../../network_policy'; export interface ChromiumDriverOptions { @@ -34,8 +34,6 @@ interface EvaluateMetaOpts { context: string; } -type ConditionalHeadersConditions = ConditionalHeaders['conditions']; - interface InterceptedRequest { requestId: string; request: { diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 6897f07c45e2bb..efef323612322c 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -64,7 +64,7 @@ export class HeadlessChromiumDriverFactory { * Return an observable to objects which will drive screenshot capture for a page */ createPage( - { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone: string }, + { viewport, browserTimezone }: { viewport: ViewportConfig; browserTimezone?: string }, pLogger: LevelLogger ): Rx.Observable<{ driver: HeadlessChromiumDriver; exit$: Rx.Observable }> { return Rx.Observable.create(async (observer: InnerSubscriber) => { diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts index 908817a2ccf818..db1e622df4e213 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.test.ts @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { cryptoFactory, LevelLogger } from '../../lib'; +import { cryptoFactory } from '../../lib'; +import { createMockLevelLogger } from '../../test_helpers'; import { decryptJobHeaders } from './'; +const logger = createMockLevelLogger(); + const encryptHeaders = async (encryptionKey: string, headers: Record) => { const crypto = cryptoFactory(encryptionKey); return await crypto.encrypt(headers); @@ -15,15 +18,11 @@ const encryptHeaders = async (encryptionKey: string, headers: Record { test(`fails if it can't decrypt headers`, async () => { const getDecryptedHeaders = () => - decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', - job: { - headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', - }, - logger: ({ - error: jest.fn(), - } as unknown) as LevelLogger, - }); + decryptJobHeaders( + 'abcsecretsauce', + 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', + logger + ); await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); @@ -36,15 +35,7 @@ describe('headers', () => { }; const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); - const decryptedHeaders = await decryptJobHeaders({ - encryptionKey: 'abcsecretsauce', - job: { - title: 'cool-job-bro', - type: 'csv', - headers: encryptedHeaders, - }, - logger: {} as LevelLogger, - }); + const decryptedHeaders = await decryptJobHeaders('abcsecretsauce', encryptedHeaders, logger); expect(decryptedHeaders).toEqual(headers); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts index 4f0088467dd689..131a7936e34631 100644 --- a/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/decrypt_job_headers.ts @@ -7,24 +7,13 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory, LevelLogger } from '../../lib'; -interface HasEncryptedHeaders { - headers?: string; -} - -export const decryptJobHeaders = async < - JobParamsType, - TaskPayloadType extends HasEncryptedHeaders ->({ - encryptionKey, - job, - logger, -}: { - encryptionKey?: string; - job: TaskPayloadType; - logger: LevelLogger; -}): Promise> => { +export const decryptJobHeaders = async ( + encryptionKey: string | undefined, + headers: string, + logger: LevelLogger +): Promise> => { try { - if (typeof job.headers !== 'string') { + if (typeof headers !== 'string') { throw new Error( i18n.translate('xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage', { defaultMessage: 'Job headers are missing', @@ -32,7 +21,7 @@ export const decryptJobHeaders = async < ); } const crypto = cryptoFactory(encryptionKey); - const decryptedHeaders = (await crypto.decrypt(job.headers)) as Record; + const decryptedHeaders = (await crypto.decrypt(headers)) as Record; return decryptedHeaders; } catch (err) { logger.error(err); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts index 794ea9febb5c08..b1d6f6fdf79c17 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.test.ts @@ -6,7 +6,6 @@ import { ReportingConfig } from '../../'; import { createMockConfig, createMockConfigSchema } from '../../test_helpers'; -import { BasePayload } from '../../types'; import { getConditionalHeaders } from './'; let mockConfig: ReportingConfig; @@ -24,11 +23,7 @@ describe('conditions', () => { baz: 'quix', }; - const conditionalHeaders = getConditionalHeaders({ - job: {} as BasePayload, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); + const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders); expect(conditionalHeaders.conditions.hostname).toEqual( mockConfig.get('kibanaServer', 'hostname') @@ -49,19 +44,7 @@ describe('config formatting', () => { const mockSchema = createMockConfigSchema(reportingConfig); mockConfig = createMockConfig(mockSchema); - const conditionalHeaders = getConditionalHeaders({ - job: { - title: 'cool-job-bro', - type: 'csv', - jobParams: { - savedObjectId: 'abc-123', - isImmediate: false, - savedObjectType: 'search', - }, - }, - filteredHeaders: {}, - config: mockConfig, - }); + const conditionalHeaders = getConditionalHeaders(mockConfig, {}); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts index ce83323914eb8b..d167ac21635b18 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_conditional_headers.ts @@ -5,17 +5,12 @@ */ import { ReportingConfig } from '../../'; -import { ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from './'; -export const getConditionalHeaders = ({ - config, - job, - filteredHeaders, -}: { - config: ReportingConfig; - job: TaskPayloadType; - filteredHeaders: Record; -}) => { +export const getConditionalHeaders = ( + config: ReportingConfig, + filteredHeaders: Record +) => { const { kbnConfig } = config; const [hostname, port, basePath, protocol] = [ config.get('kibanaServer', 'hostname'), diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts index fae66b26a83e0c..6a4e21b08996e4 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.test.ts @@ -10,11 +10,6 @@ import { TaskPayloadPNG } from '../png/types'; import { TaskPayloadPDF } from '../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; -interface FullUrlsOpts { - job: TaskPayloadPNG & TaskPayloadPDF; - config: ReportingConfig; -} - let mockConfig: ReportingConfig; beforeEach(() => { @@ -30,7 +25,7 @@ beforeEach(() => { const getMockJob = (base: object) => base as TaskPayloadPNG & TaskPayloadPDF; test(`fails if no URL is passed`, async () => { - const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({})); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -39,11 +34,7 @@ test(`fails if no URL is passed`, async () => { test(`fails if URLs are file-protocols for PNGs`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'file://etc/passwd/#/something'; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` ); @@ -53,11 +44,7 @@ test(`fails if URLs are absolute for PNGs`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` ); @@ -67,13 +54,13 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => - getFullUrls({ - job: getMockJob({ + getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` ); @@ -84,13 +71,13 @@ test(`fails if URLs are absolute for PDF`, async () => { const relativeUrl = 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => - getFullUrls({ - job: getMockJob({ + getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [relativeUrl], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` ); @@ -104,22 +91,14 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { 'file://etc/passwd/#/something', ]; - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrls, forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrls, forceNow })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` ); }); test(`fails if URL does not route to a visualization`, async () => { - const fn = () => - getFullUrls({ - job: getMockJob({ relativeUrl: '/app/phoney' }), - config: mockConfig, - } as FullUrlsOpts); + const fn = () => getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/phoney' })); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` ); @@ -127,10 +106,10 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }) + ); expect(urls[0]).toEqual( 'http://localhost:5601/sbp/app/kibana#/something?forceNow=2000-01-01T00%3A00%3A00.000Z' @@ -140,10 +119,10 @@ test(`adds forceNow to hash's query, if it exists`, async () => { test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }) + ); expect(urls[0]).toEqual( 'http://localhost:5601/sbp/app/kibana#/something?_g=something&forceNow=2000-01-01T00%3A00%3A00.000Z' @@ -151,18 +130,16 @@ test(`appends forceNow to hash's query, if it exists`, async () => { }); test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { - const urls = await getFullUrls({ - job: getMockJob({ relativeUrl: '/app/kibana#/something' }), - config: mockConfig, - } as FullUrlsOpts); + const urls = await getFullUrls(mockConfig, getMockJob({ relativeUrl: '/app/kibana#/something' })); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); }); test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; - const urls = await getFullUrls({ - job: getMockJob({ + const urls = await getFullUrls( + mockConfig, + getMockJob({ relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -170,9 +147,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }), - config: mockConfig, - } as FullUrlsOpts); + }) + ); expect(urls).toEqual([ 'http://localhost:5601/sbp/app/kibana#/something_aaa?forceNow=2000-01-01T00%3A00%3A00.000Z', diff --git a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts index f4e3a7b723c088..7621a95083bc75 100644 --- a/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_full_urls.ts @@ -23,13 +23,7 @@ function isPdfJob(job: TaskPayloadPNG | TaskPayloadPDF): job is TaskPayloadPDF { return (job as TaskPayloadPDF).relativeUrls !== undefined; } -export function getFullUrls({ - config, - job, -}: { - config: ReportingConfig; - job: TaskPayloadPDF | TaskPayloadPNG; -}) { +export function getFullUrls(config: ReportingConfig, job: TaskPayloadPDF | TaskPayloadPNG) { const [basePath, protocol, hostname, port] = [ config.kbnConfig.get('server', 'basePath'), config.get('kibanaServer', 'protocol'), diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index 80eaa52d0951b6..5fa313c8a2fb71 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -9,3 +9,21 @@ export { getConditionalHeaders } from './get_conditional_headers'; export { getFullUrls } from './get_full_urls'; export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; + +export interface TimeRangeParams { + timezone: string; + min?: Date | string | number | null; + max?: Date | string | number | null; +} + +export interface ConditionalHeadersConditions { + protocol: string; + hostname: string; + port: number; + basePath: string; +} + +export interface ConditionalHeaders { + headers: Record; + conditions: ConditionalHeadersConditions; +} diff --git a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts index f40651603db8f0..1833c2a7c62d7b 100644 --- a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.test.ts @@ -24,20 +24,9 @@ test(`omits blocked headers`, async () => { trailer: 's are for trucks', }; - const filteredHeaders = await omitBlockedHeaders({ - job: { - title: 'cool-job-bro', - type: 'csv', - jobParams: { - savedObjectId: 'abc-123', - isImmediate: false, - savedObjectType: 'search', - }, - }, - decryptedHeaders: { - ...permittedHeaders, - ...blockedHeaders, - }, + const filteredHeaders = omitBlockedHeaders({ + ...permittedHeaders, + ...blockedHeaders, }); expect(filteredHeaders).toEqual(permittedHeaders); diff --git a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts index 946f033b4b481d..09512ae7030769 100644 --- a/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts +++ b/x-pack/plugins/reporting/server/export_types/common/omit_blocked_headers.ts @@ -9,13 +9,7 @@ import { KBN_SCREENSHOT_HEADER_BLOCK_LIST_STARTS_WITH_PATTERN, } from '../../../common/constants'; -export const omitBlockedHeaders = ({ - job, - decryptedHeaders, -}: { - job: TaskPayloadType; - decryptedHeaders: Record; -}) => { +export const omitBlockedHeaders = (decryptedHeaders: Record) => { const filteredHeaders: Record = omitBy( decryptedHeaders, (_value, header: string) => diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index d768dc6f8e0843..cb60b218818f04 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -6,10 +6,11 @@ import { cryptoFactory } from '../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../types'; -import { JobParamsDiscoverCsv } from './types'; +import { IndexPatternSavedObject, JobParamsCSV, TaskPayloadCSV } from './types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); @@ -18,10 +19,10 @@ export const createJobFnFactory: CreateJobFnFactory, - TaskPayloadCSV, + CreateJobFn, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index 214157db51cb79..78615a0e7b72c6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -8,16 +8,6 @@ import { BaseParams, BasePayload } from '../../types'; export type RawValue = string | object | null | undefined; -interface DocValueField { - field: string; - format: string; -} - -interface SortOptions { - order: string; - unmapped_type: string; -} - export interface IndexPatternSavedObject { title: string; timeFieldName: string; @@ -28,25 +18,23 @@ export interface IndexPatternSavedObject { }; } -export interface JobParamsDiscoverCsv extends BaseParams { - browserTimezone: string; - indexPatternId: string; - title: string; +interface BaseParamsCSV { searchRequest: SearchRequest; fields: string[]; metaFields: string[]; conflictedTypesFields: string[]; } -export interface TaskPayloadCSV extends BasePayload { - browserTimezone: string; - basePath: string; - searchRequest: any; - fields: any; - indexPatternSavedObject: any; - metaFields: any; - conflictedTypesFields: any; -} +export type JobParamsCSV = BaseParamsCSV & + BaseParams & { + indexPatternId: string; + }; + +// CSV create job method converts indexPatternID to indexPatternSavedObject +export type TaskPayloadCSV = BaseParamsCSV & + BasePayload & { + indexPatternSavedObject: IndexPatternSavedObject; + }; export interface SearchRequest { index: string; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts index 1746792981a21f..c780247dd61b34 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/create_job.ts @@ -6,57 +6,40 @@ import { notFound, notImplemented } from 'boom'; import { get } from 'lodash'; -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { RequestHandlerContext } from 'src/core/server'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { cryptoFactory } from '../../lib'; -import { CreateJobFnFactory, TimeRangeParams } from '../../types'; +import { CsvFromSavedObjectRequest } from '../../routes/generate_from_savedobject_immediate'; +import { CreateJobFnFactory } from '../../types'; import { JobParamsPanelCsv, + JobPayloadPanelCsv, SavedObject, SavedObjectReference, SavedObjectServiceError, - SavedSearchObjectAttributesJSON, - SearchPanel, VisObjectAttributesJSON, } from './types'; export type ImmediateCreateJobFn = ( jobParams: JobParamsPanelCsv, - headers: KibanaRequest['headers'], context: RequestHandlerContext, - req: KibanaRequest -) => Promise<{ - type: string; - title: string; - jobParams: JobParamsPanelCsv; -}>; - -interface VisData { - title: string; - visType: string; - panel: SearchPanel; -} + req: CsvFromSavedObjectRequest +) => Promise; export const createJobFnFactory: CreateJobFnFactory = function createJobFactoryFn( reporting, parentLogger ) { - const config = reporting.getConfig(); - const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); - return async function createJob(jobParams, headers, context, req) { + return async function createJob(jobParams, context, req) { const { savedObjectType, savedObjectId } = jobParams; - const serializedEncryptedHeaders = await crypto.encrypt(headers); - const { panel, title, visType }: VisData = await Promise.resolve() + const panel = await Promise.resolve() .then(() => context.core.savedObjects.client.get(savedObjectType, savedObjectId)) .then(async (savedObject: SavedObject) => { const { attributes, references } = savedObject; - const { - kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON, - } = attributes as SavedSearchObjectAttributesJSON; - const { timerange } = req.body as { timerange: TimeRangeParams }; + const { kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON } = attributes; + const { timerange } = req.body; if (!kibanaSavedObjectMetaJSON) { throw new Error('Could not parse saved object data!'); @@ -85,7 +68,7 @@ export const createJobFnFactory: CreateJobFnFactory = func throw new Error('Could not find index pattern for the saved search!'); } - const sPanel = { + return { attributes: { ...attributes, kibanaSavedObjectMeta: { searchSource }, @@ -93,8 +76,6 @@ export const createJobFnFactory: CreateJobFnFactory = func indexPatternSavedObjectId: indexPatternMeta.id, timerange, }; - - return { panel: sPanel, title: attributes.title, visType: 'search' }; }) .catch((err: Error) => { const boomErr = (err as unknown) as { isBoom: boolean }; @@ -109,11 +90,6 @@ export const createJobFnFactory: CreateJobFnFactory = func throw new Error(`Unable to create a job from saved object data! Error: ${err}`); }); - return { - headers: serializedEncryptedHeaders, - jobParams: { ...jobParams, panel, visType }, - type: visType, - title, - }; + return { ...jobParams, panel }; }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts index 0ca80581fcc83d..19348c0a678d78 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts @@ -7,16 +7,11 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { CancellationToken } from '../../../common'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { BasePayload, RunTaskFnFactory, TaskRunResult } from '../../types'; +import { TaskRunResult } from '../../lib/tasks'; +import { RunTaskFnFactory } from '../../types'; import { createGenerateCsv } from '../csv/generate_csv'; import { getGenerateCsvParams } from './lib/get_csv_job'; -import { JobParamsPanelCsv, SearchPanel } from './types'; - -/* - * The run function receives the full request which provides the un-encrypted - * headers, so encrypted headers are not part of these kind of job params - */ -type ImmediateJobParams = Omit, 'headers'>; +import { JobPayloadPanelCsv } from './types'; /* * ImmediateExecuteFn receives the job doc payload because the payload was @@ -24,7 +19,7 @@ type ImmediateJobParams = Omit, 'headers'>; */ export type ImmediateExecuteFn = ( jobId: null, - job: ImmediateJobParams, + job: JobPayloadPanelCsv, context: RequestHandlerContext, req: KibanaRequest ) => Promise; @@ -36,20 +31,16 @@ export const runTaskFnFactory: RunTaskFnFactory = function e const config = reporting.getConfig(); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - return async function runTask(jobId: string | null, jobPayload, context, req) { - // There will not be a jobID for "immediate" generation. - // jobID is only for "queued" jobs - // Use the jobID as a logging tag or "immediate" - const { jobParams } = jobPayload; + return async function runTask(jobId, jobPayload, context, req) { const jobLogger = logger.clone(['immediate']); const generateCsv = createGenerateCsv(jobLogger); - const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; + const { panel, visType } = jobPayload; jobLogger.debug(`Execute job generating [${visType}] csv`); const savedObjectsClient = context.core.savedObjects.client; const uiSettingsClient = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const job = await getGenerateCsvParams(jobParams, panel, savedObjectsClient, uiSettingsClient); + const job = await getGenerateCsvParams(jobPayload, panel, savedObjectsClient, uiSettingsClient); const elasticsearch = reporting.getElasticsearchService(); const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts index 4b4cfb3f062bf2..abe9fbf3e39506 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/index.ts @@ -17,7 +17,6 @@ import { ExportTypeDefinition } from '../../types'; import { createJobFnFactory, ImmediateCreateJobFn } from './create_job'; import { ImmediateExecuteFn, runTaskFnFactory } from './execute_job'; import { metadata } from './metadata'; -import { JobParamsPanelCsv } from './types'; /* * These functions are exported to share with the API route handler that @@ -27,9 +26,7 @@ export { createJobFnFactory } from './create_job'; export { runTaskFnFactory } from './execute_job'; export const getExportType = (): ExportTypeDefinition< - JobParamsPanelCsv, ImmediateCreateJobFn, - JobParamsPanelCsv, ImmediateExecuteFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts index b387245406fbbf..acf749584c6cde 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.test.ts @@ -13,7 +13,7 @@ describe('Get CSV Job', () => { let mockSavedObjectsClient: any; let mockUiSettingsClient: any; beforeEach(() => { - mockJobParams = { isImmediate: true, savedObjectType: 'search', savedObjectId: '234-ididid' }; + mockJobParams = { savedObjectType: 'search', savedObjectId: '234-ididid' }; mockSearchPanel = { indexPatternSavedObjectId: '123-indexId', attributes: { diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts index 26a4b17aaf71fe..1fe64a25ebcaa6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_csv_job.ts @@ -12,7 +12,7 @@ import { IIndexPattern, Query, } from '../../../../../../../src/plugins/data/server'; -import { TimeRangeParams } from '../../../types'; +import { TimeRangeParams } from '../../common'; import { GenerateCsvParams } from '../../csv/generate_csv'; import { DocValueFields, @@ -50,11 +50,11 @@ export const getGenerateCsvParams = async ( savedObjectsClient: SavedObjectsClientContract, uiConfig: IUiSettingsClient ): Promise => { - let timerange: TimeRangeParams; + let timerange: TimeRangeParams | null; if (jobParams.post?.timerange) { timerange = jobParams.post?.timerange; } else { - timerange = panel.timerange; + timerange = panel.timerange || null; } const { indexPatternSavedObjectId } = panel; const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes; @@ -137,7 +137,7 @@ export const getGenerateCsvParams = async ( }; return { - browserTimezone: timerange.timezone, + browserTimezone: timerange?.timezone, indexPatternSavedObject, searchRequest, fields: includes, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts index 429b2c518cf146..75e979aa2ec015 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TimeRangeParams } from '../../../types'; +import { TimeRangeParams } from '../../common'; import { QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; import { getFilters } from './get_filters'; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts index a1b04cca0419d4..8827a30d370d4c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/lib/get_filters.ts @@ -6,7 +6,7 @@ import { badRequest } from 'boom'; import moment from 'moment-timezone'; -import { TimeRangeParams } from '../../../types'; +import { TimeRangeParams } from '../../common'; import { Filter, QueryFilter, SavedSearchObjectAttributes, SearchSourceFilter } from '../types'; export function getFilters( diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts index 9c45d23b13a371..cca79747110d56 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobParamPostPayload, TimeRangeParams } from '../../types'; +import { TimeRangeParams } from '../common'; export interface FakeRequest { - headers: Record; + headers: Record; } -export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { +export interface JobParamsPanelCsvPost { + timerange?: TimeRangeParams; state?: any; } export interface SearchPanel { indexPatternSavedObjectId: string; attributes: SavedSearchObjectAttributes; - timerange: TimeRangeParams; + timerange?: TimeRangeParams; } export interface JobPayloadPanelCsv extends JobParamsPanelCsv { @@ -27,8 +28,7 @@ export interface JobPayloadPanelCsv extends JobParamsPanelCsv { export interface JobParamsPanelCsv { savedObjectType: string; savedObjectId: string; - isImmediate: boolean; - post?: JobParamsPostPayloadPanelCsv; + post?: JobParamsPanelCsvPost; visType?: string; } diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index 3727b2ec7b432b..eaaa11d4611561 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -7,10 +7,11 @@ import { cryptoFactory } from '../../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPNG } from '../types'; +import { JobParamsPNG, TaskPayloadPNG } from '../types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index 67fc51bbfc352e..e6b36643900dd4 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -8,7 +8,8 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; -import { RunTaskFn, RunTaskFnFactory, TaskRunResult } from '../../..//types'; +import { TaskRunResult } from '../../../lib/tasks'; +import { RunTaskFn, RunTaskFnFactory } from '../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -18,12 +19,9 @@ import { import { generatePngObservableFactory } from '../lib/generate_png'; import { TaskPayloadPNG } from '../types'; -type QueuedPngExecutorFactory = RunTaskFnFactory>; - -export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFactoryFn( - reporting, - parentLogger -) { +export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); const logger = parentLogger.clone([PNG_JOB_TYPE, 'execute']); @@ -36,11 +34,11 @@ export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFac const generatePngObservable = await generatePngObservableFactory(reporting); const jobLogger = logger.clone([jobId]); const process$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), - map((decryptedHeaders) => omitBlockedHeaders({ job, decryptedHeaders })), - map((filteredHeaders) => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, logger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls(config, job); const hashUrl = urls[0]; if (apmGetAssets) apmGetAssets.end(); @@ -60,7 +58,6 @@ export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFac content_type: 'image/png', content: base64, size: (base64 && base64.length) || 0, - warnings, }; }), diff --git a/x-pack/plugins/reporting/server/export_types/png/index.ts b/x-pack/plugins/reporting/server/export_types/png/index.ts index 1cc6836572b7b3..50e09a9984b2cc 100644 --- a/x-pack/plugins/reporting/server/export_types/png/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/index.ts @@ -19,9 +19,7 @@ import { metadata } from './metadata'; import { JobParamsPNG, TaskPayloadPNG } from './types'; export const getExportType = (): ExportTypeDefinition< - JobParamsPNG, CreateJobFn, - TaskPayloadPNG, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts index 096d0bd428214a..786936d43424c9 100644 --- a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts @@ -11,7 +11,7 @@ import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; import { LayoutParams, PreserveLayout } from '../../../lib/layouts'; import { ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders } from '../../common'; export async function generatePngObservableFactory(reporting: ReportingCore) { const getScreenshots = await reporting.getScreenshotsObservable(); @@ -19,7 +19,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { return function generatePngObservable( logger: LevelLogger, url: string, - browserTimezone: string, + browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams ): Rx.Observable<{ base64: string | null; warnings: string[] }> { diff --git a/x-pack/plugins/reporting/server/export_types/png/types.d.ts b/x-pack/plugins/reporting/server/export_types/png/types.d.ts index 373b709592ed24..1f99082c757c62 100644 --- a/x-pack/plugins/reporting/server/export_types/png/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/png/types.d.ts @@ -4,19 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BaseParams, BasePayload } from '../../../server/types'; import { LayoutParams } from '../../lib/layouts'; +import { BaseParams, BasePayload } from '../../types'; -// Job params: structure of incoming user request data -export interface JobParamsPNG extends BaseParams { - title: string; +interface BaseParamsPNG { + layout: LayoutParams; + forceNow?: string; relativeUrl: string; } +// Job params: structure of incoming user request data +export type JobParamsPNG = BaseParamsPNG & BaseParams; + // Job payload: structure of stored job data provided by create_job -export interface TaskPayloadPNG extends BasePayload { - browserTimezone: string; - forceNow?: string; - layout: LayoutParams; - relativeUrl: string; -} +export type TaskPayloadPNG = BaseParamsPNG & BasePayload; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts index cae706a479b7f1..07eed00401bace 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts @@ -7,10 +7,11 @@ import { cryptoFactory } from '../../../lib'; import { CreateJobFn, CreateJobFnFactory } from '../../../types'; import { validateUrls } from '../../common'; -import { JobParamsPDF } from '../types'; +import { JobParamsPDF, TaskPayloadPDF } from '../types'; export const createJobFnFactory: CreateJobFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index f3dc5bd656f73d..ea0d60a9fad12a 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -8,7 +8,8 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; -import { RunTaskFn, RunTaskFnFactory, TaskRunResult } from '../../../types'; +import { TaskRunResult } from '../../../lib/tasks'; +import { RunTaskFn, RunTaskFnFactory } from '../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -19,12 +20,9 @@ import { generatePdfObservableFactory } from '../lib/generate_pdf'; import { getCustomLogo } from '../lib/get_custom_logo'; import { TaskPayloadPDF } from '../types'; -type QueuedPdfExecutorFactory = RunTaskFnFactory>; - -export const runTaskFnFactory: QueuedPdfExecutorFactory = function executeJobFactoryFn( - reporting, - parentLogger -) { +export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); const encryptionKey = config.get('encryptionKey'); @@ -39,12 +37,12 @@ export const runTaskFnFactory: QueuedPdfExecutorFactory = function executeJobFac const jobLogger = logger.clone([jobId]); const process$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), - map((decryptedHeaders) => omitBlockedHeaders({ job, decryptedHeaders })), - map((filteredHeaders) => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, logger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), mergeMap((conditionalHeaders) => getCustomLogo(reporting, conditionalHeaders, job.spaceId)), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ config, job }); + const urls = getFullUrls(config, job); const { browserTimezone, layout, title } = job; if (apmGetAssets) apmGetAssets.end(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts index cf3ec9cdc8c2d0..26704693ee489a 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts @@ -19,9 +19,7 @@ import { metadata } from './metadata'; import { JobParamsPDF, TaskPayloadPDF } from './types'; export const getExportType = (): ExportTypeDefinition< - JobParamsPDF, CreateJobFn, - TaskPayloadPDF, RunTaskFn > => ({ ...metadata, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 17624c1bedb57a..2cf5b69835d1f3 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -9,9 +9,9 @@ import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; -import { createLayout, LayoutInstance, LayoutParams } from '../../../lib/layouts'; +import { createLayout, LayoutParams } from '../../../lib/layouts'; import { ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders } from '../../common'; // @ts-ignore untyped module import { pdf } from './pdf'; import { getTracker } from './tracker'; @@ -35,7 +35,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { logger: LevelLogger, title: string, urls: string[], - browserTimezone: string, + browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams, logo?: string @@ -43,7 +43,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { const tracker = getTracker(); tracker.startLayout(); - const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; + const layout = createLayout(captureConfig, layoutParams); tracker.endLayout(); tracker.startScreenshots(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts index 8fa8fa5cbe3cba..426770d719069f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts @@ -11,7 +11,6 @@ import { createMockReportingCore, } from '../../../test_helpers'; import { getConditionalHeaders } from '../../common'; -import { TaskPayloadPDF } from '../types'; import { getCustomLogo } from './get_custom_logo'; let mockConfig: ReportingConfig; @@ -39,11 +38,7 @@ test(`gets logo from uiSettings`, async () => { get: mockGet, }); - const conditionalHeaders = getConditionalHeaders({ - job: {} as TaskPayloadPDF, - filteredHeaders: permittedHeaders, - config: mockConfig, - }); + const conditionalHeaders = getConditionalHeaders(mockConfig, permittedHeaders); const { logo } = await getCustomLogo(mockReportingPlugin, conditionalHeaders); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts index 35ab7001ecbe48..7bd1637db1379b 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts @@ -6,7 +6,7 @@ import { ReportingCore } from '../../../'; import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../../common/constants'; -import { ConditionalHeaders } from '../../../types'; +import { ConditionalHeaders } from '../../common'; export const getCustomLogo = async ( reporting: ReportingCore, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts index 7fd176e71f2d58..cef5c42856ff1b 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts @@ -4,20 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BaseParams, BasePayload } from '../../../server/types'; -import { LayoutInstance, LayoutParams } from '../../lib/layouts'; +import { LayoutParams } from '../../lib/layouts'; +import { BaseParams, BasePayload } from '../../types'; -// Job params: structure of incoming user request data, after being parsed from RISON -export interface JobParamsPDF extends BaseParams { - title: string; +interface BaseParamsPDF { + layout: LayoutParams; + forceNow?: string; relativeUrls: string[]; - layout: LayoutInstance; } +// Job params: structure of incoming user request data, after being parsed from RISON +export type JobParamsPDF = BaseParamsPDF & BaseParams; + // Job payload: structure of stored job data provided by create_job -export interface TaskPayloadPDF extends BasePayload { - browserTimezone: string; - forceNow?: string; - layout: LayoutParams; - relativeUrls: string[]; -} +export type TaskPayloadPDF = BaseParamsPDF & BasePayload; diff --git a/x-pack/plugins/reporting/server/lib/check_license.ts b/x-pack/plugins/reporting/server/lib/check_license.ts index a764aa1f1eec63..1f8f66fe9b5eec 100644 --- a/x-pack/plugins/reporting/server/lib/check_license.ts +++ b/x-pack/plugins/reporting/server/lib/check_license.ts @@ -24,9 +24,7 @@ const messages = { }, }; -const makeManagementFeature = ( - exportTypes: Array> -) => { +const makeManagementFeature = (exportTypes: ExportTypeDefinition[]) => { return { id: 'management', checkLicense: (license?: ILicense) => { @@ -59,9 +57,7 @@ const makeManagementFeature = ( }; }; -const makeExportTypeFeature = ( - exportType: ExportTypeDefinition -) => { +const makeExportTypeFeature = (exportType: ExportTypeDefinition) => { return { id: exportType.id, checkLicense: (license?: ILicense) => { diff --git a/x-pack/plugins/reporting/server/lib/create_queue.ts b/x-pack/plugins/reporting/server/lib/create_queue.ts index 2da3d8bd47ccb3..ded21d105f2f4e 100644 --- a/x-pack/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/plugins/reporting/server/lib/create_queue.ts @@ -5,13 +5,13 @@ */ import { ReportingCore } from '../core'; -import { JobSource, TaskRunResult } from '../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; import { createTaggedLogger } from './esqueue/create_tagged_logger'; import { LevelLogger } from './level_logger'; -import { ReportingStore } from './store'; +import { ReportDocument, ReportingStore } from './store'; +import { TaskRunResult } from './tasks'; interface ESQueueWorker { on: (event: string, handler: any) => void; @@ -32,7 +32,7 @@ export interface ESQueueInstance { // GenericWorkerFn is a generic for ImmediateExecuteFn | ESQueueWorkerExecuteFn, type GenericWorkerFn = ( - jobSource: JobSource, + jobSource: ReportDocument, ...workerRestArgs: any[] ) => void | Promise; diff --git a/x-pack/plugins/reporting/server/lib/create_worker.ts b/x-pack/plugins/reporting/server/lib/create_worker.ts index c1c88dd8a54bae..7f03cefdb620eb 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.ts @@ -9,10 +9,12 @@ import { PLUGIN_ID } from '../../common/constants'; import { durationToNumber } from '../../common/schema_utils'; import { ReportingCore } from '../../server'; import { LevelLogger } from '../../server/lib'; -import { ExportTypeDefinition, JobSource, RunTaskFn } from '../../server/types'; +import { RunTaskFn } from '../../server/types'; import { ESQueueInstance } from './create_queue'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; +import { ReportDocument } from './store'; +import { ReportTaskParams } from './tasks'; export function createWorkerFactory(reporting: ReportingCore, logger: LevelLogger) { const config = reporting.getConfig(); @@ -23,18 +25,16 @@ export function createWorkerFactory(reporting: ReportingCore, log // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { // export type / execute job map - const jobExecutors: Map> = new Map(); + const jobExecutors: Map = new Map(); - for (const exportType of reporting.getExportTypesRegistry().getAll() as Array< - ExportTypeDefinition> - >) { + for (const exportType of reporting.getExportTypesRegistry().getAll()) { const jobExecutor = exportType.runTaskFnFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); } - const workerFn = ( - jobSource: JobSource, - jobParams: TaskPayloadType, + const workerFn = ( + jobSource: ReportDocument, + payload: ReportTaskParams['payload'], cancellationToken: CancellationToken ) => { const { @@ -52,7 +52,7 @@ export function createWorkerFactory(reporting: ReportingCore, log } // pass the work to the jobExecutor - return jobTypeExecutor(jobId, jobParams, cancellationToken); + return jobTypeExecutor(jobId, payload, cancellationToken); }; const workerOptions = { diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index 5acc6e38dddf9d..305247e6f86373 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -6,7 +6,8 @@ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { ReportingCore } from '../'; -import { BaseParams, CreateJobFn, ReportingUser } from '../types'; +import { durationToNumber } from '../../common/schema_utils'; +import { BaseParams, ReportingUser } from '../types'; import { LevelLogger } from './'; import { Report } from './store'; @@ -23,6 +24,13 @@ export function enqueueJobFactory( parentLogger: LevelLogger ): EnqueueJobFn { const logger = parentLogger.clone(['queue-job']); + const config = reporting.getConfig(); + const jobSettings = { + timeout: durationToNumber(config.get('queue', 'timeout')), + browser_type: config.get('capture', 'browser', 'type'), + max_attempts: config.get('capture', 'maxAttempts'), + priority: 10, // unused + }; return async function enqueueJob( exportTypeId: string, @@ -31,8 +39,6 @@ export function enqueueJobFactory( context: RequestHandlerContext, request: KibanaRequest ) { - type CreateJobFnType = CreateJobFn; - const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); if (exportType == null) { @@ -40,15 +46,24 @@ export function enqueueJobFactory( } const [createJob, { store }] = await Promise.all([ - exportType.createJobFnFactory(reporting, logger) as CreateJobFnType, + exportType.createJobFnFactory(reporting, logger), reporting.getPluginStartDeps(), ]); - // add encrytped headers - const payload = await createJob(jobParams, context, request); + const job = await createJob(jobParams, context, request); + const pendingReport = new Report({ + jobtype: exportType.jobType, + created_by: user ? user.username : false, + payload: job, + meta: { + objectType: jobParams.objectType, + layout: jobParams.layout?.id, + }, + ...jobSettings, + }); // store the pending report, puts it in the Reporting Management UI table - const report = await store.addReport(exportType.jobType, user, payload); + const report = await store.addReport(pendingReport); logger.info(`Scheduled ${exportType.name} report: ${report._id}`); diff --git a/x-pack/plugins/reporting/server/lib/export_types_registry.ts b/x-pack/plugins/reporting/server/lib/export_types_registry.ts index 1159221a9224ea..e93cdba48a26a5 100644 --- a/x-pack/plugins/reporting/server/lib/export_types_registry.ts +++ b/x-pack/plugins/reporting/server/lib/export_types_registry.ts @@ -9,21 +9,16 @@ import { getExportType as getTypeCsv } from '../export_types/csv'; import { getExportType as getTypeCsvFromSavedObject } from '../export_types/csv_from_savedobject'; import { getExportType as getTypePng } from '../export_types/png'; import { getExportType as getTypePrintablePdf } from '../export_types/printable_pdf'; -import { ExportTypeDefinition } from '../types'; +import { CreateJobFn, ExportTypeDefinition } from '../types'; -type GetCallbackFn = ( - item: ExportTypeDefinition -) => boolean; -// => ExportTypeDefinition +type GetCallbackFn = (item: ExportTypeDefinition) => boolean; export class ExportTypesRegistry { - private _map: Map> = new Map(); + private _map: Map = new Map(); constructor() {} - register( - item: ExportTypeDefinition - ): void { + register(item: ExportTypeDefinition): void { if (!isString(item.id)) { throw new Error(`'item' must have a String 'id' property `); } @@ -43,35 +38,21 @@ export class ExportTypesRegistry { return this._map.size; } - getById( - id: string - ): ExportTypeDefinition { + getById(id: string): ExportTypeDefinition { if (!this._map.has(id)) { throw new Error(`Unknown id ${id}`); } - return this._map.get(id) as ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType - >; + return this._map.get(id) as ExportTypeDefinition; } - get( - findType: GetCallbackFn - ): ExportTypeDefinition { + get(findType: GetCallbackFn): ExportTypeDefinition { let result; for (const value of this._map.values()) { if (!findType(value)) { continue; // try next value } - const foundResult: ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType - > = value; + const foundResult: ExportTypeDefinition = value; if (result) { throw new Error('Found multiple items matching predicate.'); @@ -88,13 +69,19 @@ export class ExportTypesRegistry { } } +// TODO: Define a 2nd ExportTypeRegistry instance for "immediate execute" report job types only. +// It should not require a `CreateJobFn` for its ExportTypeDefinitions, which only makes sense for async. +// Once that is done, the `any` types below can be removed. + +/* + * @return ExportTypeRegistry: the ExportTypeRegistry instance that should be + * used to register async export type definitions + */ export function getExportTypesRegistry(): ExportTypesRegistry { const registry = new ExportTypesRegistry(); - - /* this replaces the previously async method of registering export types, - * where this would run a directory scan and types would be registered via - * discovery */ - const getTypeFns: Array<() => ExportTypeDefinition> = [ + type CreateFnType = CreateJobFn; // can not specify params types because different type of params are not assignable to each other + type RunFnType = any; // can not specify because ImmediateExecuteFn is not assignable to RunTaskFn + const getTypeFns: Array<() => ExportTypeDefinition> = [ getTypeCsv, getTypeCsvFromSavedObject, getTypePng, diff --git a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts index 921d302387edf6..585175aac82c56 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/create_layout.ts @@ -6,12 +6,11 @@ import { CaptureConfig } from '../../types'; import { LayoutParams, LayoutTypes } from './'; -import { Layout } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { - if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { +export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams) { + if (layoutParams && layoutParams.dimensions && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } diff --git a/x-pack/plugins/reporting/server/lib/layouts/index.ts b/x-pack/plugins/reporting/server/lib/layouts/index.ts index 507b7614072eaf..c091339a605823 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/index.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/index.ts @@ -53,7 +53,7 @@ export interface Size { export interface LayoutParams { id: string; - dimensions: Size; + dimensions?: Size; selectors?: LayoutSelectorDictionary; } @@ -64,4 +64,4 @@ interface LayoutSelectors { positionElements?: (browser: HeadlessChromiumDriver, logger: LevelLogger) => Promise; } -export type LayoutInstance = Layout & LayoutSelectors & Size; +export type LayoutInstance = Layout & LayoutSelectors & Partial; diff --git a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts index e8d182dac0b1dc..cecd761fbcf326 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/preserve_layout.ts @@ -12,12 +12,13 @@ import { LayoutTypes, PageSizeParams, Size, + LayoutInstance, } from './'; // We use a zoom of two to bump up the resolution of the screenshot a bit. const ZOOM: number = 2; -export class PreserveLayout extends Layout { +export class PreserveLayout extends Layout implements LayoutInstance { public readonly selectors: LayoutSelectorDictionary = getDefaultLayoutSelectors(); public readonly groupCount = 1; public readonly height: number; diff --git a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts index b055fae8a780dc..33f16bc7865d56 100644 --- a/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts +++ b/x-pack/plugins/reporting/server/lib/layouts/print_layout.ts @@ -9,10 +9,16 @@ import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { LevelLogger } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { getDefaultLayoutSelectors, LayoutSelectorDictionary, LayoutTypes, Size } from './'; +import { + getDefaultLayoutSelectors, + LayoutInstance, + LayoutSelectorDictionary, + LayoutTypes, + Size, +} from './'; import { Layout } from './layout'; -export class PrintLayout extends Layout { +export class PrintLayout extends Layout implements LayoutInstance { public readonly selectors: LayoutSelectorDictionary = { ...getDefaultLayoutSelectors(), screenshot: '[data-shared-item]', diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts index afd63644548352..5f7919df4e9fd5 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_time_range.ts @@ -5,7 +5,7 @@ */ import { LevelLogger, startTrace } from '../'; -import { LayoutInstance } from '../../../common/types'; +import { LayoutInstance } from '../layouts'; import { HeadlessChromiumDriver } from '../../browsers'; import { CONTEXT_GETTIMERANGE } from './constants'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index c1d33cb5193843..1b9722fb494585 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -6,7 +6,7 @@ import * as Rx from 'rxjs'; import { LevelLogger } from '../'; -import { ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from '../../export_types/common'; import { LayoutInstance } from '../layouts'; export { screenshotsObservableFactory } from './observable'; @@ -16,7 +16,7 @@ export interface ScreenshotObservableOpts { urls: string[]; conditionalHeaders: ConditionalHeaders; layout: LayoutInstance; - browserTimezone: string; + browserTimezone?: string; } export interface AttributesMap { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 5b671e9f5b47e9..798f926cd0a312 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -18,6 +18,7 @@ jest.mock('../../browsers/chromium/puppeteer', () => ({ import moment from 'moment'; import * as Rx from 'rxjs'; import { HeadlessChromiumDriver } from '../../browsers'; +import { ConditionalHeaders } from '../../export_types/common'; import { createMockBrowserDriverFactory, createMockConfig, @@ -25,7 +26,6 @@ import { createMockLayoutInstance, createMockLevelLogger, } from '../../test_helpers'; -import { ConditionalHeaders } from '../../types'; import { ElementsPositionAndAttribute } from './'; import * as contexts from './constants'; import { screenshotsObservableFactory } from './observable'; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index e28f50851f4d91..e8b7f91764efd1 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -5,10 +5,11 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig, ConditionalHeaders } from '../../types'; +import { ConditionalHeaders } from '../../export_types/common'; +import { CaptureConfig } from '../../types'; export const openUrl = async ( captureConfig: CaptureConfig, diff --git a/x-pack/plugins/reporting/server/lib/store/index.ts b/x-pack/plugins/reporting/server/lib/store/index.ts index a88d36d3fdf9ae..a48f2661203236 100644 --- a/x-pack/plugins/reporting/server/lib/store/index.ts +++ b/x-pack/plugins/reporting/server/lib/store/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Report } from './report'; +export { Report, ReportDocument } from './report'; export { ReportingStore } from './store'; diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index 9ac5d1f87c387f..1e4a833c7cabe2 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -14,7 +14,8 @@ describe('Class Report', () => { created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { headers: 'payload_test_field', objectType: 'testOt', title: 'cool report' }, + meta: { objectType: 'test' }, timeout: 30000, priority: 1, }); @@ -25,11 +26,10 @@ describe('Class Report', () => { attempts: 0, browser_type: 'browser_type_test_string', completed_at: undefined, - created_at: undefined, created_by: 'created_by_test_string', jobtype: 'test-report', max_attempts: 50, - meta: undefined, + meta: { objectType: 'test' }, payload: { headers: 'payload_test_field', objectType: 'testOt' }, priority: 1, started_at: undefined, @@ -38,12 +38,16 @@ describe('Class Report', () => { }, }); expect(report.toApiJSON()).toMatchObject({ + attempts: 0, browser_type: 'browser_type_test_string', created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', jobtype: 'test-report', max_attempts: 50, payload: { headers: 'payload_test_field', objectType: 'testOt' }, + meta: { objectType: 'test' }, priority: 1, + status: 'pending', timeout: 30000, }); @@ -57,7 +61,8 @@ describe('Class Report', () => { created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { headers: 'payload_test_field', objectType: 'testOt', title: 'hot report' }, + meta: { objectType: 'stange' }, timeout: 30000, priority: 1, }); @@ -70,51 +75,46 @@ describe('Class Report', () => { }; report.updateWithEsDoc(metadata); - expect(report.toEsDocsJSON()).toMatchInlineSnapshot(` - Object { - "_id": "12342p9o387549o2345", - "_index": ".reporting-test-update", - "_source": Object { - "attempts": 0, - "browser_type": "browser_type_test_string", - "completed_at": undefined, - "created_at": undefined, - "created_by": "created_by_test_string", - "jobtype": "test-report", - "max_attempts": 50, - "meta": undefined, - "payload": Object { - "headers": "payload_test_field", - "objectType": "testOt", - }, - "priority": 1, - "started_at": undefined, - "status": "pending", - "timeout": 30000, - }, - } - `); - expect(report.toApiJSON()).toMatchInlineSnapshot(` - Object { - "attempts": 0, - "browser_type": "browser_type_test_string", - "completed_at": undefined, - "created_at": undefined, - "created_by": "created_by_test_string", - "id": "12342p9o387549o2345", - "index": ".reporting-test-update", - "jobtype": "test-report", - "max_attempts": 50, - "meta": undefined, - "payload": Object { - "headers": "payload_test_field", - "objectType": "testOt", - }, - "priority": 1, - "started_at": undefined, - "status": "pending", - "timeout": 30000, - } - `); + expect(report.toEsDocsJSON()).toMatchObject({ + _id: '12342p9o387549o2345', + _index: '.reporting-test-update', + _source: { + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { objectType: 'testOt' }, + priority: 1, + started_at: undefined, + status: 'pending', + timeout: 30000, + }, + }); + expect(report.toApiJSON()).toMatchObject({ + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_by: 'created_by_test_string', + id: '12342p9o387549o2345', + index: '.reporting-test-update', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'stange' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + priority: 1, + started_at: undefined, + status: 'pending', + timeout: 30000, + }); + }); + + it('throws error if converted to task JSON before being synced with ES storage', () => { + const report = new Report({} as any); + expect(() => report.updateWithEsDoc(report)).toThrowErrorMatchingInlineSnapshot( + `"Report object from ES has missing fields!"` + ); }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 5c9b9ced7cce75..d82b90f4025ed2 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -4,84 +4,96 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; // @ts-ignore no module definition import Puid from 'puid'; +import { JobStatus, ReportApiJSON } from '../../../common/types'; import { JobStatuses } from '../../../constants'; -import { LayoutInstance } from '../layouts'; +import { LayoutParams } from '../layouts'; +import { TaskRunResult } from '../tasks'; -/* - * The document created by Reporting to store in the .reporting index - */ -interface ReportingDocument { +interface ReportDocumentHead { _id: string; _index: string; _seq_no: unknown; _primary_term: unknown; +} + +/* + * The document created by Reporting to store in the .reporting index + */ +export interface ReportDocument extends ReportDocumentHead { + _source: ReportSource; +} + +export interface ReportSource { jobtype: string; + kibana_name: string; + kibana_id: string; created_by: string | false; payload: { headers: string; // encrypted headers + browserTimezone?: string; // may use timezone from advanced settings objectType: string; - layout?: LayoutInstance; + title: string; + layout?: LayoutParams; }; - meta: unknown; + meta: { objectType: string; layout?: string }; browser_type: string; max_attempts: number; timeout: number; - status: string; + status: JobStatus; attempts: number; - output?: unknown; + output: TaskRunResult | null; started_at?: string; completed_at?: string; - created_at?: string; + created_at: string; priority?: number; process_expiration?: string; } -/* - * The document created by Reporting to store as task parameters for Task - * Manager to reference the report in .reporting - */ const puid = new Puid(); -export class Report implements Partial { +export class Report implements Partial { public _index?: string; public _id: string; public _primary_term?: unknown; // set by ES public _seq_no: unknown; // set by ES - public readonly jobtype: string; - public readonly created_at?: string; - public readonly created_by?: string | false; - public readonly payload: { - headers: string; // encrypted headers - objectType: string; - layout?: LayoutInstance; - }; - public readonly meta: unknown; - public readonly max_attempts: number; - public readonly browser_type?: string; - - public readonly status: string; - public readonly attempts: number; - public readonly output?: unknown; - public readonly started_at?: string; - public readonly completed_at?: string; - public readonly process_expiration?: string; - public readonly priority?: number; - public readonly timeout?: number; + public readonly kibana_name: ReportSource['kibana_name']; + public readonly kibana_id: ReportSource['kibana_id']; + public readonly jobtype: ReportSource['jobtype']; + public readonly created_at: ReportSource['created_at']; + public readonly created_by: ReportSource['created_by']; + public readonly payload: ReportSource['payload']; + + public readonly meta: ReportSource['meta']; + public readonly max_attempts: ReportSource['max_attempts']; + public readonly browser_type?: ReportSource['browser_type']; + + public readonly status: ReportSource['status']; + public readonly attempts: ReportSource['attempts']; + public readonly output?: ReportSource['output']; + public readonly started_at?: ReportSource['started_at']; + public readonly completed_at?: ReportSource['completed_at']; + public readonly process_expiration?: ReportSource['process_expiration']; + public readonly priority?: ReportSource['priority']; + public readonly timeout?: ReportSource['timeout']; /* * Create an unsaved report + * Index string is required */ - constructor(opts: Partial) { + constructor(opts: Partial & Partial) { this._id = opts._id != null ? opts._id : puid.generate(); this._index = opts._index; this._primary_term = opts._primary_term; this._seq_no = opts._seq_no; this.payload = opts.payload!; + this.kibana_name = opts.kibana_name!; + this.kibana_id = opts.kibana_id!; this.jobtype = opts.jobtype!; this.max_attempts = opts.max_attempts!; this.attempts = opts.attempts || 0; @@ -89,9 +101,9 @@ export class Report implements Partial { this.process_expiration = opts.process_expiration; this.timeout = opts.timeout; - this.created_at = opts.created_at; - this.created_by = opts.created_by; - this.meta = opts.meta; + this.created_at = opts.created_at || moment.utc().toISOString(); + this.created_by = opts.created_by || false; + this.meta = opts.meta || { objectType: 'unknown' }; this.browser_type = opts.browser_type; this.priority = opts.priority; @@ -141,10 +153,12 @@ export class Report implements Partial { /* * Data structure for API responses */ - toApiJSON() { + toApiJSON(): ReportApiJSON { return { id: this._id, - index: this._index, + index: this._index!, + kibana_name: this.kibana_name, + kibana_id: this.kibana_id, jobtype: this.jobtype, created_at: this.created_at, created_by: this.created_by, diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 8dc4edd2000527..931eae8b246c47 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -48,28 +48,25 @@ describe('ReportingStore', () => { describe('addReport', () => { it('returns Report object', async () => { const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_1', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'username1' }, reportPayload) - ).resolves.toMatchObject({ + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + status: 'pending', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, completed_at: undefined, created_by: 'username1', jobtype: 'unknowntype', - max_attempts: undefined, payload: {}, - priority: 10, - started_at: undefined, + meta: {}, status: 'pending', - timeout: 120000, }); }); @@ -83,15 +80,15 @@ describe('ReportingStore', () => { mockCore = await createMockReportingCore(mockConfig); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_2', - objectType: 'testOt', - }; - expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: Invalid index interval: centurially]`); + const mockReport = new Report({ + _index: '.reporting-errortest', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[TypeError: this.client.callAsInternalUser is not a function]` + ); }); it('handles error creating the index', async () => { @@ -100,15 +97,15 @@ describe('ReportingStore', () => { callClusterStub.withArgs('indices.create').rejects(new Error('horrible error')); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_3', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: horrible error]`); + const mockReport = new Report({ + _index: '.reporting-errortest', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[Error: horrible error]` + ); }); /* Creating the index will fail, if there were multiple jobs staged in @@ -123,15 +120,15 @@ describe('ReportingStore', () => { callClusterStub.withArgs('indices.create').rejects(new Error('devastating error')); const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_4', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).rejects.toMatchInlineSnapshot(`[Error: devastating error]`); + const mockReport = new Report({ + _index: '.reporting-mock', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).rejects.toMatchInlineSnapshot( + `[Error: devastating error]` + ); }); it('skips creating the index if already exists', async () => { @@ -142,28 +139,20 @@ describe('ReportingStore', () => { .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_headers_5', - objectType: 'testOt', - }; - await expect( - store.addReport(reportType, { username: 'user1' }, reportPayload) - ).resolves.toMatchObject({ + const mockReport = new Report({ + created_by: 'user1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, - completed_at: undefined, created_by: 'user1', jobtype: 'unknowntype', - max_attempts: undefined, payload: {}, - priority: 10, - started_at: undefined, status: 'pending', - timeout: 120000, }); }); @@ -175,26 +164,24 @@ describe('ReportingStore', () => { .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored const store = new ReportingStore(mockCore, mockLogger); - const reportType = 'unknowntype'; - const reportPayload = { - browserTimezone: 'UTC', - headers: 'rp_test_headers', - objectType: 'testOt', - }; - await expect(store.addReport(reportType, false, reportPayload)).resolves.toMatchObject({ + const mockReport = new Report({ + _index: '.reporting-unsecured', + attempts: 0, + created_by: false, + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'pending', + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, attempts: 0, - browser_type: undefined, - completed_at: undefined, created_by: false, jobtype: 'unknowntype', - max_attempts: undefined, + meta: {}, payload: {}, - priority: 10, - started_at: undefined, status: 'pending', - timeout: 120000, }); }); }); @@ -209,8 +196,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'ABC', }, timeout: 30000, priority: 1, @@ -248,8 +237,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'BCD', }, timeout: 30000, priority: 1, @@ -287,8 +278,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'CDE', }, timeout: 30000, priority: 1, @@ -326,8 +319,10 @@ describe('ReportingStore', () => { browser_type: 'browser_type_test_string', max_attempts: 50, payload: { + title: 'test report', headers: 'rp_test_headers', objectType: 'testOt', + browserTimezone: 'utc', }, timeout: 30000, priority: 1, diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 03d88ca60e2c0a..c20a9e991b4bc5 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -5,21 +5,12 @@ */ import { ElasticsearchServiceSetup } from 'src/core/server'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; -import { BaseParams, BaseParamsEncryptedFields, ReportingUser } from '../../types'; import { indexTimestamp } from './index_timestamp'; import { mapping } from './mapping'; import { Report } from './report'; -interface JobSettings { - timeout: number; - browser_type: string; - max_attempts: number; - priority: number; -} - const checkReportIsEditable = (report: Report) => { if (!report._id || !report._index) { throw new Error(`Report object is not synced with ES!`); @@ -35,7 +26,6 @@ const checkReportIsEditable = (report: Report) => { export class ReportingStore { private readonly indexPrefix: string; private readonly indexInterval: string; - private readonly jobSettings: JobSettings; private client: ElasticsearchServiceSetup['legacy']['client']; private logger: LevelLogger; @@ -46,13 +36,6 @@ export class ReportingStore { this.client = elasticsearch.legacy.client; this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); - this.jobSettings = { - timeout: durationToNumber(config.get('queue', 'timeout')), - browser_type: config.get('capture', 'browser', 'type'), - max_attempts: config.get('capture', 'maxAttempts'), - priority: 10, // unused - }; - this.logger = logger; } @@ -101,36 +84,17 @@ export class ReportingStore { * Called from addReport, which handles any errors */ private async indexReport(report: Report) { - const params = report.payload; - - // Queing is handled by TM. These queueing-based fields for reference in Report Info panel - const infoFields = { - timeout: report.timeout, - process_expiration: new Date(0), // use epoch so the job query works - created_at: new Date(), - attempts: 0, - max_attempts: report.max_attempts, - status: statuses.JOB_STATUS_PENDING, - browser_type: report.browser_type, - }; - - const indexParams = { + const doc = { index: report._index, id: report._id, body: { - ...infoFields, - jobtype: report.jobtype, - meta: { - // We are copying these values out of payload because these fields are indexed and can be aggregated on - // for tracking stats, while payload contents are not. - objectType: params.objectType, - layout: params.layout ? params.layout.id : 'none', - }, - payload: report.payload, - created_by: report.created_by, + ...report.toEsDocsJSON()._source, + process_expiration: new Date(0), // use epoch so the job query works + attempts: 0, + status: statuses.JOB_STATUS_PENDING, }, }; - return await this.client.callAsInternalUser('index', indexParams); + return await this.client.callAsInternalUser('index', doc); } /* @@ -140,23 +104,15 @@ export class ReportingStore { return await this.client.callAsInternalUser('indices.refresh', { index }); } - public async addReport( - type: string, - user: ReportingUser, - payload: BaseParams & BaseParamsEncryptedFields - ): Promise { - const timestamp = indexTimestamp(this.indexInterval); - const index = `${this.indexPrefix}-${timestamp}`; + public async addReport(report: Report): Promise { + let index = report._index; + if (!index) { + const timestamp = indexTimestamp(this.indexInterval); + index = `${this.indexPrefix}-${timestamp}`; + report._index = index; + } await this.createIndex(index); - const report = new Report({ - _index: index, - payload, - jobtype: type, - created_by: user ? user.username : false, - ...this.jobSettings, - }); - try { const doc = await this.indexReport(report); report.updateWithEsDoc(doc); @@ -166,7 +122,7 @@ export class ReportingStore { return report; } catch (err) { - this.logger.error(`Error in addReport!`); + this.logger.error(`Error in adding a report!`); this.logger.error(err); throw err; } @@ -220,7 +176,7 @@ export class ReportingStore { public async setReportCompleted(report: Report, stats: Partial): Promise { try { - const { output } = stats as { output: any }; + const { output } = stats; const status = output && output.warnings && output.warnings.length > 0 ? statuses.JOB_STATUS_WARNINGS diff --git a/x-pack/plugins/reporting/server/lib/tasks/index.ts b/x-pack/plugins/reporting/server/lib/tasks/index.ts new file mode 100644 index 00000000000000..0dd9945985bfb5 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/tasks/index.ts @@ -0,0 +1,32 @@ +/* + * 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 { BasePayload } from '../../types'; +import { ReportSource } from '../store/report'; + +/* + * The document created by Reporting to store as task parameters for Task + * Manager to reference the report in .reporting + */ +export interface ReportTaskParams { + id: string; + index?: string; // For ad-hoc, which as an existing "pending" record + payload: JobPayloadType; + created_at: ReportSource['created_at']; + created_by: ReportSource['created_by']; + jobtype: ReportSource['jobtype']; + attempts: ReportSource['attempts']; + meta: ReportSource['meta']; +} + +export interface TaskRunResult { + content_type: string | null; + content: string | null; + csv_contains_formulas?: boolean; + size: number; + max_size_reached?: boolean; + warnings?: string[]; +} diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts index 33620bc9a00383..dc4b30ffcfa7cb 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/browser.ts @@ -9,8 +9,8 @@ import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { browserStartLogs } from '../../browsers/chromium/driver_factory/start_logs'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; const logsToHelpMap = { 'error while loading shared libraries': i18n.translate( diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts index 95c3a05bbf6809..70428779366b3f 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/config.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/config.ts @@ -10,8 +10,8 @@ import { defaults, get } from 'lodash'; import { ReportingCore } from '../..'; import { API_DIAGNOSE_URL } from '../../../common/constants'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts index 895dee32614f1f..84df91ea31b622 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/index.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/index.ts @@ -15,3 +15,9 @@ export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logge registerDiagnoseConfig(reporting, logger); registerDiagnoseScreenshot(reporting, logger); }; + +export interface DiagnosticResponse { + help: string[]; + success: boolean; + logs: string; +} diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 0acf384869dedb..6ea6e22c5d7f91 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -11,8 +11,8 @@ import { omitBlockedHeaders } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; import { LevelLogger as Logger } from '../../lib'; -import { DiagnosticResponse } from '../../types'; import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing'; +import { DiagnosticResponse } from './'; export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Logger) => { const setupDeps = reporting.getPluginSetupDeps(); @@ -54,10 +54,7 @@ export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Log }; const headers = { - headers: omitBlockedHeaders({ - job: null, - decryptedHeaders, - }), + headers: omitBlockedHeaders(decryptedHeaders), conditions: { hostname, port: +port, diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 517f1dadc0ac15..400fbb16f54dcb 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -10,17 +10,20 @@ import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { createJobFnFactory } from '../export_types/csv_from_savedobject/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/execute_job'; -import { JobParamsPostPayloadPanelCsv } from '../export_types/csv_from_savedobject/types'; +import { + JobParamsPanelCsv, + JobParamsPanelCsvPost, +} from '../export_types/csv_from_savedobject/types'; import { LevelLogger as Logger } from '../lib'; -import { TaskRunResult } from '../types'; +import { TaskRunResult } from '../lib/tasks'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; export type CsvFromSavedObjectRequest = KibanaRequest< - { savedObjectType: string; savedObjectId: string }, + JobParamsPanelCsv, unknown, - JobParamsPostPayloadPanelCsv + JobParamsPanelCsvPost >; /* @@ -66,27 +69,22 @@ export function registerGenerateCsvFromSavedObjectImmediate( }, userHandler(async (user, context, req: CsvFromSavedObjectRequest, res) => { const logger = parentLogger.clone(['savedobject-csv']); - const jobParams = getJobParamsFromRequest(req, { isImmediate: true }); + const jobParams = getJobParamsFromRequest(req); const createJob = createJobFnFactory(reporting, logger); const runTaskFn = runTaskFnFactory(reporting, logger); try { // FIXME: no create job for immediate download - const jobDocPayload = await createJob(jobParams, req.headers, context, req); + const payload = await createJob(jobParams, context, req); const { content_type: jobOutputContentType, content: jobOutputContent, size: jobOutputSize, - }: TaskRunResult = await runTaskFn(null, jobDocPayload, context, req); + }: TaskRunResult = await runTaskFn(null, payload, context, req); logger.info(`Job output size: ${jobOutputSize} bytes`); - /* - * ESQueue worker function defaults `content` to null, even if the - * runTask returned undefined. - * - * This converts null to undefined so the value can be sent to h.response() - */ + // convert null to undefined so the value can be sent to h.response() if (jobOutputContent === null) { logger.warn('CSV Job Execution created empty content result'); } diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index dd905223a81d53..867af75c8de27d 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -74,8 +74,8 @@ describe('POST /api/reporting/generate', () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', validLicenses: ['basic', 'gold'], - createJobFnFactory: () => () => ({ jobParamsTest: { test1: 'yes' } }), - runTaskFnFactory: () => () => ({ runParamsTest: { test2: 'yes' } }), + createJobFnFactory: () => async () => ({ createJobTest: { test1: 'yes' } } as any), + runTaskFnFactory: () => async () => ({ runParamsTest: { test2: 'yes' } } as any), }); core.getExportTypesRegistry = () => mockExportTypesRegistry; }); @@ -163,9 +163,21 @@ describe('POST /api/reporting/generate', () => { .then(({ body }) => { expect(body).toMatchObject({ job: { - id: expect.any(String), + attempts: 0, + created_by: 'Tom Riddle', + id: 'foo', + index: 'foo-index', + jobtype: 'printable_pdf', + payload: { + createJobTest: { + test1: 'yes', + }, + }, + priority: 10, + status: 'pending', + timeout: 10000, }, - path: expect.any(String), + path: 'undefined/api/reporting/jobs/download/foo', }); }); }); diff --git a/x-pack/plugins/reporting/server/routes/index.ts b/x-pack/plugins/reporting/server/routes/index.ts index 11ad4cc9d4eb81..22edd4002dbcf0 100644 --- a/x-pack/plugins/reporting/server/routes/index.ts +++ b/x-pack/plugins/reporting/server/routes/index.ts @@ -15,3 +15,10 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerJobInfoRoutes(reporting); registerDiagnosticRoutes(reporting, logger); } + +export interface ReportingRequestPre { + management: { + jobTypes: string[]; + }; + user: string; +} diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 187c69f4a72ef7..fc1cfd00493c33 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -25,7 +25,6 @@ describe('GET /api/reporting/jobs/download', () => { let core: ReportingCore; const config = createMockConfig(createMockConfigSchema()); - const getHits = (...sources: any) => { return { hits: { @@ -69,14 +68,14 @@ describe('GET /api/reporting/jobs/download', () => { jobType: 'unencodedJobType', jobContentExtension: 'csv', validLicenses: ['basic', 'gold'], - } as ExportTypeDefinition); + } as ExportTypeDefinition); exportTypesRegistry.register({ id: 'base64Encoded', jobType: 'base64EncodedJobType', jobContentEncoding: 'base64', jobContentExtension: 'pdf', validLicenses: ['basic', 'gold'], - } as ExportTypeDefinition); + } as ExportTypeDefinition); core.getExportTypesRegistry = () => exportTypesRegistry; }); diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index db62c0cc403fc0..43e73c137fb13a 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -128,7 +128,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { } return res.ok({ - body: jobOutput, + body: jobOutput || {}, headers: { 'content-type': 'application/json', }, diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index 84a98d6d1f1d7a..b154978d041f45 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -9,9 +9,9 @@ import contentDisposition from 'content-disposition'; import { get } from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ExportTypesRegistry, statuses } from '../../lib'; -import { ExportTypeDefinition, JobSource, TaskRunResult } from '../../types'; - -type ExportTypeType = ExportTypeDefinition; +import { ReportDocument } from '../../lib/store'; +import { TaskRunResult } from '../../lib/tasks'; +import { ExportTypeDefinition } from '../../types'; interface ErrorFromPayload { message: string; @@ -27,10 +27,10 @@ interface Payload { const DEFAULT_TITLE = 'report'; -const getTitle = (exportType: ExportTypeType, title?: string): string => +const getTitle = (exportType: ExportTypeDefinition, title?: string): string => `${title || DEFAULT_TITLE}.${exportType.jobContentExtension}`; -const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeType) => { +const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefinition) => { const metaDataHeaders: Record = {}; if (exportType.jobType === CSV_JOB_TYPE) { @@ -45,7 +45,10 @@ const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeType) }; export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { - function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { + function encodeContent( + content: string | null, + exportType: ExportTypeDefinition + ): Buffer | string { switch (exportType.jobContentEncoding) { case 'base64': return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string @@ -55,7 +58,9 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist } function getCompleted(output: TaskRunResult, jobType: string, title: string): Payload { - const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); + const exportType = exportTypesRegistry.get( + (item: ExportTypeDefinition) => item.jobType === jobType + ); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -92,16 +97,18 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } - return function getDocumentPayload(doc: JobSource): Payload { + return function getDocumentPayload(doc: ReportDocument): Payload { const { status, jobtype: jobType, payload: { title } = { title: '' } } = doc._source; const { output } = doc._source; - if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { - return getCompleted(output, jobType, title); - } + if (output) { + if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { + return getCompleted(output, jobType, title); + } - if (status === statuses.JOB_STATUS_FAILED) { - return getFailure(output); + if (status === statuses.JOB_STATUS_FAILED) { + return getFailure(output); + } } // send a 503 indicating that the report isn't completed yet diff --git a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts index bfa15a4022a4d6..e685339c966ed0 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -7,17 +7,13 @@ import { JobParamsPanelCsv } from '../../export_types/csv_from_savedobject/types'; import { CsvFromSavedObjectRequest } from '../generate_from_savedobject_immediate'; -export function getJobParamsFromRequest( - request: CsvFromSavedObjectRequest, - opts: { isImmediate: boolean } -): JobParamsPanelCsv { +export function getJobParamsFromRequest(request: CsvFromSavedObjectRequest): JobParamsPanelCsv { const { savedObjectType, savedObjectId } = request.params; const { timerange, state } = request.body; const post = timerange || state ? { timerange, state } : undefined; return { - isImmediate: opts.isImmediate, savedObjectType, savedObjectId, post, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index b01c880abe8205..d1270215b48213 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -8,7 +8,8 @@ import { i18n } from '@kbn/i18n'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { get } from 'lodash'; import { ReportingCore } from '../../'; -import { JobSource, ReportingUser } from '../../types'; +import { ReportDocument } from '../../lib/store'; +import { ReportingUser } from '../../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -130,7 +131,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { }); }, - get(user: ReportingUser, id: string, opts: GetOpts = {}): Promise | void> { + get(user: ReportingUser, id: string, opts: GetOpts = {}): Promise { if (!id) return Promise.resolve(); const username = getUsername(user); diff --git a/x-pack/plugins/reporting/server/routes/types.d.ts b/x-pack/plugins/reporting/server/routes/types.d.ts index 5c34d466197fe1..b3f9225c3dce55 100644 --- a/x-pack/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/plugins/reporting/server/routes/types.d.ts @@ -18,11 +18,11 @@ export type HandlerFunction = ( export type HandlerErrorFunction = (res: KibanaResponseFactory, err: Error) => any; -export interface QueuedJobPayload { +export interface QueuedJobPayload { error?: boolean; source: { job: { - payload: BasePayload; + payload: BasePayload; }; }; } diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index a3c63a0fb539d5..eb046a3eab0752 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -13,81 +13,11 @@ import { CancellationToken } from '../../../plugins/reporting/common'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { LicensingPluginSetup } from '../../licensing/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../security/server'; -import { JobStatus } from '../common/types'; import { ReportingConfigType } from './config'; import { ReportingCore } from './core'; import { LevelLogger } from './lib'; -import { LayoutInstance } from './lib/layouts'; - -/* - * Routing types - */ - -export interface ReportingRequestPre { - management: { - jobTypes: string[]; - }; - user: string; -} - -// generate a report with unparsed jobParams -export interface GenerateExportTypePayload { - jobParams: string; -} - -export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPayload; - -export interface TimeRangeParams { - timezone: string; - min?: Date | string | number | null; - max?: Date | string | number | null; -} - -// the "raw" data coming from the client, unencrypted -export interface JobParamPostPayload { - timerange?: TimeRangeParams; -} - -// the pre-processed, encrypted data ready for storage -export interface BasePayload { - headers: string; // serialized encrypted headers - jobParams: JobParamsType; - title: string; - type: string; - spaceId?: string; -} - -export interface JobSource { - _id: string; - _index: string; - _source: { - jobtype: string; - output: TaskRunResult; - payload: BasePayload; - status: JobStatus; - }; -} - -export interface TaskRunResult { - content_type: string | null; - content: string | null; - csv_contains_formulas?: boolean; - size: number; - max_size_reached?: boolean; - warnings?: string[]; -} - -interface ConditionalHeadersConditions { - protocol: string; - hostname: string; - port: number; - basePath: string; -} - -export interface ConditionalHeaders { - headers: Record; - conditions: ConditionalHeadersConditions; -} +import { LayoutParams } from './lib/layouts'; +import { ReportTaskParams, TaskRunResult } from './lib/tasks'; /* * Plugin Contract @@ -118,24 +48,29 @@ export type CaptureConfig = ReportingConfigType['capture']; export type ScrollConfig = ReportingConfigType['csv']['scroll']; export interface BaseParams { - browserTimezone: string; - layout?: LayoutInstance; // for screenshot type reports + browserTimezone?: string; // browserTimezone is optional: it is not in old POST URLs that were generated prior to being added to this interface + layout?: LayoutParams; objectType: string; + title: string; } -export interface BaseParamsEncryptedFields extends BaseParams { - headers: string; // encrypted headers +// base params decorated with encrypted headers that come into runJob functions +export interface BasePayload extends BaseParams { + headers: string; + spaceId?: string; } -export type CreateJobFn = ( +// default fn type for CreateJobFnFactory +export type CreateJobFn = ( jobParams: JobParamsType, context: RequestHandlerContext, - request: KibanaRequest -) => Promise; + request: KibanaRequest +) => Promise; -export type RunTaskFn = ( +// default fn type for RunTaskFnFactory +export type RunTaskFn = ( jobId: string, - job: TaskPayloadType, + payload: ReportTaskParams['payload'], cancellationToken: CancellationToken ) => Promise; @@ -149,12 +84,7 @@ export type RunTaskFnFactory = ( logger: LevelLogger ) => RunTaskFnType; -export interface ExportTypeDefinition< - JobParamsType, - CreateJobFnType, - JobPayloadType, - RunTaskFnType -> { +export interface ExportTypeDefinition { id: string; name: string; jobType: string; @@ -164,9 +94,3 @@ export interface ExportTypeDefinition< runTaskFnFactory: RunTaskFnFactory; validLicenses: string[]; } - -export interface DiagnosticResponse { - help: string[]; - success: boolean; - logs: string; -}